From 646c5782b92f289e1d7f2a872f69828bf7e4343b Mon Sep 17 00:00:00 2001 From: "stephen.worsley" Date: Thu, 21 Jan 2021 12:45:54 +0000 Subject: [PATCH 01/19] add abstract summary --- lib/iris/_representation.py | 283 ++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 lib/iris/_representation.py diff --git a/lib/iris/_representation.py b/lib/iris/_representation.py new file mode 100644 index 0000000000..40609a92e5 --- /dev/null +++ b/lib/iris/_representation.py @@ -0,0 +1,283 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Provides objects describing cube summaries. +""" + +import iris.util + + +def sorted_axes(axes): + """ + Returns the axis names sorted alphabetically, with the exception that + 't', 'z', 'y', and, 'x' are sorted to the end. + """ + return sorted( + axes, + key=lambda name: ({"x": 4, "y": 3, "z": 2, "t": 1}.get(name, 0), name), + ) + + +class DimensionHeader: + def __init__(self, cube): + if cube.shape == (): + self.scalar = True + self.dim_names = [] + self.shape = [] + self.contents = ["scalar cube"] + else: + self.scalar = False + self.dim_names = [] + for dim in range(len(cube.shape)): + dim_coords = cube.coords( + contains_dimension=dim, dim_coords=True + ) + if dim_coords: + self.dim_names.append(dim_coords[0].name()) + else: + self.dim_names.append("-- ") + self.shape = list(cube.shape) + self.contents = [ + name + ": %d" % dim_len + for name, dim_len in zip(self.dim_names, self.shape) + ] + + +class FullHeader: + def __init__(self, cube, name_padding=35): + self.name = cube.name() + self.unit = cube.units + self.nameunit = "{name} / ({units})".format( + name=self.name, units=self.unit + ) + self.name_padding = name_padding + self.dimension_header = DimensionHeader(cube) + + +class CoordSummary: + def _summary_coord_extra(self, cube, coord): + # Returns the text needed to ensure this coordinate can be + # distinguished from all others with the same name. + extra = "" + similar_coords = cube.coords(coord.name()) + if len(similar_coords) > 1: + # Find all the attribute keys + keys = set() + for similar_coord in similar_coords: + keys.update(similar_coord.attributes.keys()) + # Look for any attributes that vary + vary = set() + attributes = {} + for key in keys: + for similar_coord in similar_coords: + if key not in similar_coord.attributes: + vary.add(key) + break + value = similar_coord.attributes[key] + if attributes.setdefault(key, value) != value: + vary.add(key) + break + keys = sorted(vary & set(coord.attributes.keys())) + bits = [ + "{}={!r}".format(key, coord.attributes[key]) for key in keys + ] + if bits: + extra = ", ".join(bits) + return extra + + +class VectorSummary(CoordSummary): + def __init__(self, cube, vector, iscoord): + vector_indent = 10 + extra_indent = 13 + self.name = iris.util.clip_string( + vector.name(), clip_length=70 - vector_indent + ) + dims = vector.cube_dims(cube) + self.dim_chars = [ + "x" if dim in dims else "-" for dim in range(len(cube.shape)) + ] + if iscoord: + extra = self._summary_coord_extra() + self.extra = iris.util.clip_string( + extra, clip_length=70 - extra_indent + ) + else: + self.extra = "" + + +class ScalarSummary(CoordSummary): + def __init__(self, coord): + extra_indent = 13 + self.name = coord.name() + if ( + coord.units in ["1", "no_unit", "unknown"] + or coord.units.is_time_reference() + ): + self.unit = "" + else: + self.unit = " {!s}".format(coord.units) + coord_cell = coord.cell(0) + if isinstance(coord_cell.point, str): + self.string_type = True + 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) + else: + self.string_type = False + self.point = "{!s}".format(coord_cell.point) + coord_cell_cbound = coord_cell.bound + if coord_cell_cbound is not None: + self.bound = "({})".format( + ", ".join(str(val) for val in coord_cell_cbound) + ) + else: + self.bound = None + self.lines = None + self.content = "{}{}, bound={}{}".format( + self.point, self.unit, self.bound, self.unit + ) + extra = self._summary_coord_extra() + self.extra = iris.util.clip_string( + extra, clip_length=70 - extra_indent + ) + + +class Section: + def is_empty(self): + return self.contents == [] + + +class VectorSection(Section): + def __init__(self, title, vectors): + self.title = title + self.contents = [VectorSummary(vector) for vector in vectors] + + +class ScalarSection(Section): + def __init__(self, title, scalars): + self.title = title + self.contents = [ScalarSummary(scalar) for scalar in scalars] + + +class ScalarCMSection(Section): + def __init__(self, title, cell_measures): + self.title = title + self.contents = [cm.name() for cm in cell_measures] + + +class AttributeSection(Section): + def __init__(self, title, attributes): + self.title = title + self.names = [] + self.values = [] + self.contents = [] + for name, value in sorted(attributes.items()): + value = iris.util.clip_string(str(value)) + self.names.append(name) + self.values.append(value) + content = "{}: {}".format(name, value) + self.contents.append(content) + + +class CellMethodSection(Section): + def __init__(self, title, cell_methods): + self.title = title + self.contents = [str(cm) for cm in cell_methods] + + +class CubeSummary: + 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 + # object IDs. + derived_coords = cube.derived_coords + # Determine the cube coordinates that are scalar (single-valued) + # AND non-dimensioned. + dim_coords = cube.dim_coords + aux_coords = cube.aux_coords + all_coords = dim_coords + aux_coords + derived_coords + scalar_coords = [ + coord + for coord in all_coords + if not cube.coord_dims(coord) and coord.shape == (1,) + ] + # Determine the cube coordinates that are not scalar BUT + # dimensioned. + scalar_coord_ids = set(map(id, scalar_coords)) + vector_dim_coords = [ + coord for coord in dim_coords if id(coord) not in scalar_coord_ids + ] + vector_aux_coords = [ + coord for coord in aux_coords if id(coord) not in scalar_coord_ids + ] + vector_derived_coords = [ + coord + for coord in derived_coords + if id(coord) not in scalar_coord_ids + ] + + # cell measures + vector_cell_measures = [ + cm for cm in cube.cell_measures() if cm.shape != (1,) + ] + + # Ancillary Variables + vector_ancillary_variables = [av for av in cube.ancillary_variables()] + + # Sort scalar coordinates by name. + scalar_coords.sort(key=lambda coord: coord.name()) + # Sort vector coordinates by data dimension and name. + vector_dim_coords.sort( + key=lambda coord: (cube.coord_dims(coord), coord.name()) + ) + vector_aux_coords.sort( + key=lambda coord: (cube.coord_dims(coord), coord.name()) + ) + vector_derived_coords.sort( + key=lambda coord: (cube.coord_dims(coord), coord.name()) + ) + scalar_cell_measures = [ + cm for cm in cube.cell_measures() if cm.shape == (1,) + ] + + self.dim_coord_section = VectorSection( + "Dimension coordinates:", vector_dim_coords + ) + self.aux_coord_section = VectorSection( + "Auxiliary coordinates:", vector_aux_coords + ) + self.derived_coord_section = VectorSection( + "Derived coordinates:", vector_derived_coords + ) + self.cell_measure_section = VectorSection( + "Cell Measures:", vector_cell_measures + ) + self.ancillary_variable_section = VectorSection( + "Ancillary variables:", vector_ancillary_variables + ) + + self.scalar_section = ScalarSection( + "Scalar Coordinates:", scalar_coords + ) + self.scalar_cm_section = ScalarCMSection( + "Scalar cell measures:", scalar_cell_measures + ) + self.attribute_section = AttributeSection( + "Attributes:", cube.attributes + ) + self.cell_method_section = CellMethodSection( + "Cell methods:", cube.cell_methods + ) From a6098fa3982e42e9c6cf68943eb721233898e501 Mon Sep 17 00:00:00 2001 From: "stephen.worsley" Date: Thu, 21 Jan 2021 16:08:51 +0000 Subject: [PATCH 02/19] bug fix --- lib/iris/_representation.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/iris/_representation.py b/lib/iris/_representation.py index 40609a92e5..0f042b5bfe 100644 --- a/lib/iris/_representation.py +++ b/lib/iris/_representation.py @@ -156,9 +156,9 @@ def is_empty(self): class VectorSection(Section): - def __init__(self, title, vectors): + def __init__(self, title, cube, vectors, iscoord): self.title = title - self.contents = [VectorSummary(vector) for vector in vectors] + self.contents = [VectorSummary(vector, cube, iscoord) for vector in vectors] class ScalarSection(Section): @@ -254,19 +254,19 @@ def __init__(self, cube, shorten=False, name_padding=35): ] self.dim_coord_section = VectorSection( - "Dimension coordinates:", vector_dim_coords + "Dimension coordinates:", cube, vector_dim_coords, True ) self.aux_coord_section = VectorSection( - "Auxiliary coordinates:", vector_aux_coords + "Auxiliary coordinates:", cube, vector_aux_coords, True ) self.derived_coord_section = VectorSection( - "Derived coordinates:", vector_derived_coords + "Derived coordinates:", cube, vector_derived_coords, True ) self.cell_measure_section = VectorSection( - "Cell Measures:", vector_cell_measures + "Cell Measures:", cube, vector_cell_measures, False ) self.ancillary_variable_section = VectorSection( - "Ancillary variables:", vector_ancillary_variables + "Ancillary variables:", cube, vector_ancillary_variables, False ) self.scalar_section = ScalarSection( From e7ff4a78a63ca1e49dcea1ac90de1d60d3325447 Mon Sep 17 00:00:00 2001 From: "stephen.worsley" Date: Fri, 22 Jan 2021 14:02:29 +0000 Subject: [PATCH 03/19] bug fix with tests --- lib/iris/_representation.py | 6 +- .../tests/unit/representation/__init__.py | 6 ++ .../representation/test_representation.py | 64 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 lib/iris/tests/unit/representation/__init__.py create mode 100644 lib/iris/tests/unit/representation/test_representation.py diff --git a/lib/iris/_representation.py b/lib/iris/_representation.py index 0f042b5bfe..a34eced1a1 100644 --- a/lib/iris/_representation.py +++ b/lib/iris/_representation.py @@ -101,7 +101,7 @@ def __init__(self, cube, vector, iscoord): "x" if dim in dims else "-" for dim in range(len(cube.shape)) ] if iscoord: - extra = self._summary_coord_extra() + extra = self._summary_coord_extra(cube, vector) self.extra = iris.util.clip_string( extra, clip_length=70 - extra_indent ) @@ -158,7 +158,9 @@ def is_empty(self): class VectorSection(Section): def __init__(self, title, cube, vectors, iscoord): self.title = title - self.contents = [VectorSummary(vector, cube, iscoord) for vector in vectors] + self.contents = [ + VectorSummary(cube, vector, iscoord) for vector in vectors + ] class ScalarSection(Section): diff --git a/lib/iris/tests/unit/representation/__init__.py b/lib/iris/tests/unit/representation/__init__.py new file mode 100644 index 0000000000..e943ad149b --- /dev/null +++ b/lib/iris/tests/unit/representation/__init__.py @@ -0,0 +1,6 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +"""Unit tests for the :mod:`iris._representation` module.""" diff --git a/lib/iris/tests/unit/representation/test_representation.py b/lib/iris/tests/unit/representation/test_representation.py new file mode 100644 index 0000000000..2075b531d1 --- /dev/null +++ b/lib/iris/tests/unit/representation/test_representation.py @@ -0,0 +1,64 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +"""Unit tests for the :mod:`iris._representation` module.""" + +import numpy as np +import iris.tests as tests +import iris._representation +from iris.cube import Cube +from iris.coords import ( + DimCoord, + AuxCoord, + CellMeasure, + AncillaryVariable, + CellMethod, +) + + +def example_cube(): + cube = Cube( + np.arange(6).reshape([3, 2]), + standard_name="air_temperature", + long_name="screen_air_temp", + var_name="airtemp", + units="K", + ) + lat = DimCoord([0, 1, 2], standard_name="latitude", units="degrees") + cube.add_dim_coord(lat, 0) + return cube + + +class Test_CubeSummary(tests.IrisTest): + def setUp(self): + self.cube = example_cube() + + def test_header(self): + rep = iris._representation.CubeSummary(self.cube) + header_left = rep.header.nameunit + header_right = rep.header.dimension_header.contents + + self.assertEqual(header_left, "air_temperature / (K)") + self.assertEqual(header_right, ["latitude: 3", "-- : 2"]) + + def test_coord(self): + rep = iris._representation.CubeSummary(self.cube) + dim_section = rep.dim_coord_section + + self.assertEqual(len(dim_section.contents), 1) + + dim_summary = dim_section.contents[0] + + name = dim_summary.name + dim_chars = dim_summary.dim_chars + extra = dim_summary.extra + + self.assertEqual(name, "latitude") + self.assertEqual(dim_chars, ["x", "-"]) + self.assertEqual(extra, "") + + +if __name__ == "__main__": + tests.main() From 60c6e4d72799b3c1f907284cd351e27d4dc8fb8c Mon Sep 17 00:00:00 2001 From: "stephen.worsley" Date: Fri, 22 Jan 2021 14:45:22 +0000 Subject: [PATCH 04/19] fix scalar bug and add test --- lib/iris/_representation.py | 19 +++++----- .../representation/test_representation.py | 36 ++++++++++++++++++- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/lib/iris/_representation.py b/lib/iris/_representation.py index a34eced1a1..0ccc3f4cfc 100644 --- a/lib/iris/_representation.py +++ b/lib/iris/_representation.py @@ -110,7 +110,7 @@ def __init__(self, cube, vector, iscoord): class ScalarSummary(CoordSummary): - def __init__(self, coord): + def __init__(self, cube, coord): extra_indent = 13 self.name = coord.name() if ( @@ -132,19 +132,20 @@ def __init__(self, coord): self.content = "\n".join(self.lines) else: self.string_type = False + self.lines = None self.point = "{!s}".format(coord_cell.point) coord_cell_cbound = coord_cell.bound if coord_cell_cbound is not None: self.bound = "({})".format( ", ".join(str(val) for val in coord_cell_cbound) ) + self.content = "{}{}, bound={}{}".format( + self.point, self.unit, self.bound, self.unit + ) else: self.bound = None - self.lines = None - self.content = "{}{}, bound={}{}".format( - self.point, self.unit, self.bound, self.unit - ) - extra = self._summary_coord_extra() + self.content = "{}{}".format(self.point, self.unit) + extra = self._summary_coord_extra(cube, coord) self.extra = iris.util.clip_string( extra, clip_length=70 - extra_indent ) @@ -164,9 +165,9 @@ def __init__(self, title, cube, vectors, iscoord): class ScalarSection(Section): - def __init__(self, title, scalars): + def __init__(self, title, cube, scalars): self.title = title - self.contents = [ScalarSummary(scalar) for scalar in scalars] + self.contents = [ScalarSummary(cube, scalar) for scalar in scalars] class ScalarCMSection(Section): @@ -272,7 +273,7 @@ def __init__(self, cube, shorten=False, name_padding=35): ) self.scalar_section = ScalarSection( - "Scalar Coordinates:", scalar_coords + "Scalar Coordinates:", cube, scalar_coords ) self.scalar_cm_section = ScalarCMSection( "Scalar cell measures:", scalar_cell_measures diff --git a/lib/iris/tests/unit/representation/test_representation.py b/lib/iris/tests/unit/representation/test_representation.py index 2075b531d1..87191ed4d2 100644 --- a/lib/iris/tests/unit/representation/test_representation.py +++ b/lib/iris/tests/unit/representation/test_representation.py @@ -43,7 +43,7 @@ def test_header(self): self.assertEqual(header_left, "air_temperature / (K)") self.assertEqual(header_right, ["latitude: 3", "-- : 2"]) - def test_coord(self): + def test_vector_coord(self): rep = iris._representation.CubeSummary(self.cube) dim_section = rep.dim_coord_section @@ -59,6 +59,40 @@ def test_coord(self): self.assertEqual(dim_chars, ["x", "-"]) self.assertEqual(extra, "") + def test_scalar_coord(self): + cube = self.cube + scalar_coord_no_bounds = AuxCoord([10], long_name="bar", units="K") + 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"} + ) + cube.add_aux_coord(scalar_coord_no_bounds) + cube.add_aux_coord(scalar_coord_with_bounds) + cube.add_aux_coord(scalar_coord_text) + rep = iris._representation.CubeSummary(cube) + + scalar_section = rep.scalar_section + + self.assertEqual(len(scalar_section.contents), 3) + + no_bounds_summary = scalar_section.contents[0] + bounds_summary = scalar_section.contents[1] + text_summary = scalar_section.contents[2] + + self.assertEqual(no_bounds_summary.name, "bar") + self.assertEqual(no_bounds_summary.content, "10 K") + self.assertEqual(no_bounds_summary.extra, "") + + self.assertEqual(bounds_summary.name, "foo") + 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'") + if __name__ == "__main__": tests.main() From 611ce0665293e7aa15330f89a8d04b35d0bcdcd3 Mon Sep 17 00:00:00 2001 From: "stephen.worsley" Date: Fri, 22 Jan 2021 16:08:22 +0000 Subject: [PATCH 05/19] apply suggestions from PR --- lib/iris/_representation.py | 48 ++++++++++--------- .../representation/test_representation.py | 4 +- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/lib/iris/_representation.py b/lib/iris/_representation.py index 0ccc3f4cfc..8169a73483 100644 --- a/lib/iris/_representation.py +++ b/lib/iris/_representation.py @@ -256,31 +256,33 @@ def __init__(self, cube, shorten=False, name_padding=35): cm for cm in cube.cell_measures() if cm.shape == (1,) ] - self.dim_coord_section = VectorSection( - "Dimension coordinates:", cube, vector_dim_coords, True - ) - self.aux_coord_section = VectorSection( - "Auxiliary coordinates:", cube, vector_aux_coords, True - ) - self.derived_coord_section = VectorSection( - "Derived coordinates:", cube, vector_derived_coords, True - ) - self.cell_measure_section = VectorSection( - "Cell Measures:", cube, vector_cell_measures, False - ) - self.ancillary_variable_section = VectorSection( - "Ancillary variables:", cube, vector_ancillary_variables, False - ) + self.vector_sections = {} - self.scalar_section = ScalarSection( - "Scalar Coordinates:", cube, scalar_coords + def add_vector_section(title, contents, iscoord=True): + self.vector_sections[title] = VectorSection( + title, cube, contents, iscoord + ) + + 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( + "Ancillary Variables:", vector_ancillary_variables, False ) - self.scalar_cm_section = ScalarCMSection( - "Scalar cell measures:", scalar_cell_measures + + self.scalar_sections = {} + + 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 ) - self.attribute_section = AttributeSection( - "Attributes:", cube.attributes + add_scalar_section( + ScalarCMSection, "Scalar cell measures:", scalar_cell_measures ) - self.cell_method_section = CellMethodSection( - "Cell methods:", cube.cell_methods + add_scalar_section(AttributeSection, "Attributes:", cube.attributes) + add_scalar_section( + CellMethodSection, "Cell methods:", cube.cell_methods ) diff --git a/lib/iris/tests/unit/representation/test_representation.py b/lib/iris/tests/unit/representation/test_representation.py index 87191ed4d2..c6b234f076 100644 --- a/lib/iris/tests/unit/representation/test_representation.py +++ b/lib/iris/tests/unit/representation/test_representation.py @@ -45,7 +45,7 @@ def test_header(self): def test_vector_coord(self): rep = iris._representation.CubeSummary(self.cube) - dim_section = rep.dim_coord_section + dim_section = rep.vector_sections["Dimension coordinates:"] self.assertEqual(len(dim_section.contents), 1) @@ -73,7 +73,7 @@ def test_scalar_coord(self): cube.add_aux_coord(scalar_coord_text) rep = iris._representation.CubeSummary(cube) - scalar_section = rep.scalar_section + scalar_section = rep.scalar_sections["Scalar Coordinates:"] self.assertEqual(len(scalar_section.contents), 3) From 18e6fa141d6999f3de42722c5cd19e9b80a8588a Mon Sep 17 00:00:00 2001 From: "stephen.worsley" Date: Fri, 22 Jan 2021 17:04:21 +0000 Subject: [PATCH 06/19] add test coverage --- .../representation/test_representation.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/iris/tests/unit/representation/test_representation.py b/lib/iris/tests/unit/representation/test_representation.py index c6b234f076..88eb7a34cb 100644 --- a/lib/iris/tests/unit/representation/test_representation.py +++ b/lib/iris/tests/unit/representation/test_representation.py @@ -93,6 +93,32 @@ def test_scalar_coord(self): self.assertEqual(text_summary.content, "a\nb\nc") self.assertEqual(text_summary.extra, "key='value'") + def test_cell_measure(self): + cube = self.cube + cell_measure = CellMeasure([1, 2, 3], long_name="foo") + cube.add_cell_measure(cell_measure, 0) + rep = iris._representation.CubeSummary(cube) + + cm_section = rep.vector_sections["Cell Measures:"] + self.assertEqual(len(cm_section.contents), 1) + + cm_summary = cm_section.contents[0] + self.assertEqual(cm_summary.name, "foo") + self.assertEqual(cm_summary.dim_chars, ["x", "-"]) + + def test_ancillary_variable(self): + cube = self.cube + cell_measure = AncillaryVariable([1, 2, 3], long_name="foo") + cube.add_ancillary_variable(cell_measure, 0) + rep = iris._representation.CubeSummary(cube) + + av_section = rep.vector_sections["Ancillary Variables:"] + self.assertEqual(len(av_section.contents), 1) + + av_summary = av_section.contents[0] + self.assertEqual(av_summary.name, "foo") + self.assertEqual(av_summary.dim_chars, ["x", "-"]) + if __name__ == "__main__": tests.main() From 5d504ab8cf42243ffdaf4831047fb87e5389e463 Mon Sep 17 00:00:00 2001 From: "stephen.worsley" Date: Mon, 25 Jan 2021 09:49:43 +0000 Subject: [PATCH 07/19] add test coverage --- .../representation/test_representation.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/iris/tests/unit/representation/test_representation.py b/lib/iris/tests/unit/representation/test_representation.py index 88eb7a34cb..19c776be65 100644 --- a/lib/iris/tests/unit/representation/test_representation.py +++ b/lib/iris/tests/unit/representation/test_representation.py @@ -119,6 +119,31 @@ def test_ancillary_variable(self): self.assertEqual(av_summary.name, "foo") self.assertEqual(av_summary.dim_chars, ["x", "-"]) + def test_attributes(self): + cube = self.cube + cube.attributes = {"a": 1, "b": "two"} + rep = iris._representation.CubeSummary(cube) + + attribute_section = rep.scalar_sections["Attributes:"] + attribute_contents = attribute_section.contents + expected_contents = ["a: 1", "b: two"] + + self.assertEqual(attribute_contents, expected_contents) + + def test_cell_methods(self): + cube = self.cube + x = AuxCoord(1, long_name="x") + y = AuxCoord(1, long_name="y") + cell_method_xy = CellMethod("mean", [x, y]) + cell_method_x = CellMethod("mean", x) + cube.add_cell_method(cell_method_xy) + cube.add_cell_method(cell_method_x) + + rep = iris._representation.CubeSummary(cube) + cell_method_section = rep.scalar_sections["Cell methods:"] + expected_contents = ["mean: x, y", "mean: x"] + self.assertEqual(cell_method_section.contents, expected_contents) + if __name__ == "__main__": tests.main() From 890e907090274878ab71a977cbe32012f54c0ece Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Mon, 25 Jan 2021 11:28:33 +0000 Subject: [PATCH 08/19] Rename _representation to _representation/cube_summary. --- lib/iris/_representation/__init__.py | 9 +++++++++ .../cube_summary.py} | 0 ...representation.py => test_cube_summary.py} | 19 ++++++++++--------- 3 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 lib/iris/_representation/__init__.py rename lib/iris/{_representation.py => _representation/cube_summary.py} (100%) rename lib/iris/tests/unit/representation/{test_representation.py => test_cube_summary.py} (90%) diff --git a/lib/iris/_representation/__init__.py b/lib/iris/_representation/__init__.py new file mode 100644 index 0000000000..f6c7fdf9b4 --- /dev/null +++ b/lib/iris/_representation/__init__.py @@ -0,0 +1,9 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Code to make printouts and other representations (e.g. html) of Iris objects. + +""" diff --git a/lib/iris/_representation.py b/lib/iris/_representation/cube_summary.py similarity index 100% rename from lib/iris/_representation.py rename to lib/iris/_representation/cube_summary.py diff --git a/lib/iris/tests/unit/representation/test_representation.py b/lib/iris/tests/unit/representation/test_cube_summary.py similarity index 90% rename from lib/iris/tests/unit/representation/test_representation.py rename to lib/iris/tests/unit/representation/test_cube_summary.py index 19c776be65..1e3f9afbc8 100644 --- a/lib/iris/tests/unit/representation/test_representation.py +++ b/lib/iris/tests/unit/representation/test_cube_summary.py @@ -3,11 +3,10 @@ # This file is part of Iris and is released under the LGPL license. # See COPYING and COPYING.LESSER in the root of the repository for full # licensing details. -"""Unit tests for the :mod:`iris._representation` module.""" +"""Unit tests for the :mod:`iris._representation.cube_summary` module.""" import numpy as np import iris.tests as tests -import iris._representation from iris.cube import Cube from iris.coords import ( DimCoord, @@ -17,6 +16,8 @@ CellMethod, ) +from iris._representation import cube_summary + def example_cube(): cube = Cube( @@ -36,7 +37,7 @@ def setUp(self): self.cube = example_cube() def test_header(self): - rep = iris._representation.CubeSummary(self.cube) + rep = cube_summary.CubeSummary(self.cube) header_left = rep.header.nameunit header_right = rep.header.dimension_header.contents @@ -44,7 +45,7 @@ def test_header(self): self.assertEqual(header_right, ["latitude: 3", "-- : 2"]) def test_vector_coord(self): - rep = iris._representation.CubeSummary(self.cube) + rep = cube_summary.CubeSummary(self.cube) dim_section = rep.vector_sections["Dimension coordinates:"] self.assertEqual(len(dim_section.contents), 1) @@ -71,7 +72,7 @@ def test_scalar_coord(self): cube.add_aux_coord(scalar_coord_no_bounds) cube.add_aux_coord(scalar_coord_with_bounds) cube.add_aux_coord(scalar_coord_text) - rep = iris._representation.CubeSummary(cube) + rep = cube_summary.CubeSummary(cube) scalar_section = rep.scalar_sections["Scalar Coordinates:"] @@ -97,7 +98,7 @@ def test_cell_measure(self): cube = self.cube cell_measure = CellMeasure([1, 2, 3], long_name="foo") cube.add_cell_measure(cell_measure, 0) - rep = iris._representation.CubeSummary(cube) + rep = cube_summary.CubeSummary(cube) cm_section = rep.vector_sections["Cell Measures:"] self.assertEqual(len(cm_section.contents), 1) @@ -110,7 +111,7 @@ def test_ancillary_variable(self): cube = self.cube cell_measure = AncillaryVariable([1, 2, 3], long_name="foo") cube.add_ancillary_variable(cell_measure, 0) - rep = iris._representation.CubeSummary(cube) + rep = cube_summary.CubeSummary(cube) av_section = rep.vector_sections["Ancillary Variables:"] self.assertEqual(len(av_section.contents), 1) @@ -122,7 +123,7 @@ def test_ancillary_variable(self): def test_attributes(self): cube = self.cube cube.attributes = {"a": 1, "b": "two"} - rep = iris._representation.CubeSummary(cube) + rep = cube_summary.CubeSummary(cube) attribute_section = rep.scalar_sections["Attributes:"] attribute_contents = attribute_section.contents @@ -139,7 +140,7 @@ def test_cell_methods(self): cube.add_cell_method(cell_method_xy) cube.add_cell_method(cell_method_x) - rep = iris._representation.CubeSummary(cube) + rep = cube_summary.CubeSummary(cube) cell_method_section = rep.scalar_sections["Cell methods:"] expected_contents = ["mean: x, y", "mean: x"] self.assertEqual(cell_method_section.contents, expected_contents) From a01250dbdadfd280e23768481db533b7e470fa21 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Mon, 25 Jan 2021 14:48:01 +0000 Subject: [PATCH 09/19] Added cube printout; no Cube.functions yet; crude test. --- lib/iris/_representation/cube_printout.py | 217 ++++++++++++++++++ lib/iris/_representation/cube_summary.py | 6 + .../unit/representation/test_cube_printout.py | 61 +++++ 3 files changed, 284 insertions(+) create mode 100644 lib/iris/_representation/cube_printout.py create mode 100644 lib/iris/tests/unit/representation/test_cube_printout.py diff --git a/lib/iris/_representation/cube_printout.py b/lib/iris/_representation/cube_printout.py new file mode 100644 index 0000000000..ecb4ed6cdd --- /dev/null +++ b/lib/iris/_representation/cube_printout.py @@ -0,0 +1,217 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Provides text printouts of Iris cubes. + +""" + +import beautifultable as bt + + +class CubePrinter: + """ + An object created from a + :class:`iris._representation.cube_summary.CubeSummary`, which provides + text printout of a :class:`iris.cube.Cube`. + + This is the type of object now returned by :meth:`iris.cube.Cube.summary` + (when 'oneline=False') : Hence it needs to be printable, so it has a + :meth:`__str__` method which calls its :meth:`to_string`. + + The cube :meth:`iris.cube.Cube.__str__` and + :meth:`iris.cube.Cube.__repr__` methods, and + :meth:`iris.cube.Cube.summary` with 'oneline=True', also use this to + produce cube summary strings. + + It's "table" property is a :class:`beautifultable.BeautifulTable`, which + provides a representation of cube content as a flexible table object. + However, but this cannot currently produce output identical to the + :meth:`to_string` method, which uses additional techniques. + + In principle, this class does not have any internal knowledge of + :class:`iris.cube.Cube`, but only of + :class:`iris._representation.cube_summary.CubeSummary`. + + """ + + def __init__(self, cube_summary, max_width=None): + # Extract what we need from the cube summary, to produce printouts. + + if max_width is None: + max_width = 120 # Our magic best guess + self.max_width = max_width + + # Create a table to produce the printouts. + self.table = self._make_table(cube_summary) + # NOTE: owing to some problems with column width control, this does not + # encode our 'max_width' : For now, that is implemented by special code + # in :meth:`to_string`. + + def _make_table( + self, + cube_summary, + n_indent_section=5, + n_indent_item=5, + n_indent_extra=5, + ): + # Construct a beautifultable to represent the cube-summary info. + # + # NOTE: although beautifultable is useful and provides a flexible output + # form, but its formatting features are not yet adequate to produce our + # desired "standard cube summary" appearance : For that, we still need + # column spanning. + # So a 'normal' cube summary is produced by "CubePrinter.to_string()", + # which relies on information *not* stored in the table. + extra_indent = " " * n_indent_extra + sect_indent = " " * n_indent_section + item_indent = sect_indent + " " * n_indent_item + summ = cube_summary + + fullheader = summ.header + nameunits_string = fullheader.nameunit + dimheader = fullheader.dimension_header + cube_is_scalar = dimheader.scalar + assert not cube_is_scalar # Just for now... + + cube_shape = dimheader.shape # may be empty + dim_names = dimheader.dim_names # may be empty + n_dims = len(dim_names) + assert len(cube_shape) == n_dims + + # NOTE: this is of no interest, as it "decided" to make a list + # of "{dim-name}: {length}", but we don't actually use that. + # dimheader.contents + + tb = bt.BeautifulTable(max_width=self.max_width) + + # First setup the columns + # - 3 initially for the column-1 content + # - 2 (name, length) for each dimension + # - + column_texts = [nameunits_string, ""] + for dim_name, length in zip(dim_names, cube_shape): + column_texts.append(f"{dim_name}:") + column_texts.append(f"{length:d}") + + tb.columns.header = column_texts[:] # Making copy, in case (!) + + # Add rows from all the vector sections + for sect in summ.vector_sections.values(): + if sect.contents: + sect_name = sect.title + column_texts = [sect_indent + sect_name, ""] + column_texts += [""] * (2 * n_dims) + tb.rows.append(column_texts) + for vec_summary in sect.contents: + element_name = vec_summary.name + dim_chars = vec_summary.dim_chars + extra_string = vec_summary.extra + column_texts = [item_indent + element_name, ""] + for dim_char in dim_chars: + column_texts += ["", dim_char] + tb.rows.append(column_texts) + if extra_string: + column_texts = [""] * len(column_texts) + column_texts[1] = extra_indent + extra_string + + # Similar for scalar sections : different ones handle differently + for sect in summ.scalar_sections.values(): + if sect.contents: + sect_name = sect.title + column_texts = [sect_indent + sect_name, ""] + column_texts += [""] * (2 * n_dims) + tb.rows.append(column_texts) + title = sect_name.lower() + + def add_scalar(name, value): + column_texts = [item_indent + name, value] + column_texts += [""] * (2 * n_dims) + tb.rows.append(column_texts) + + if "scalar coordinate" in title: + for item in sect.contents: + add_scalar(item.name, item.content) + elif "attribute" in title: + for title, value in zip(sect.names, sect.values): + add_scalar(title, value) + elif "scalar cell measure" in title or "cell method" in title: + # These ones are "just strings". + for name in sect.contents: + add_scalar(name, "") + else: + msg = f"Unknown section type : {type(sect)}" + raise ValueError(msg) + + return tb + + def to_string(self, oneline=False, max_width=None): + """ + Produce a printable summary. + + Args: + * oneline (bool): + If set, produce a one-line summary (without any extra spacings). + Default is False = produce full (multiline) summary. + * max_width (int): + If set, override the default maximum output width. + Default is None = use the default established at object creation. + + Returns: + result (string) + + """ + if max_width is None: + max_width = self.max_width + tb = self.table + tb.set_style(bt.STYLE_COMPACT) + # Fix all the column widths and alignments + tb.maxwidth = 9999 # don't curtail or wrap *anything* + tb.columns.alignment[0] = bt.ALIGN_LEFT + tb.columns.alignment[1] = bt.ALIGN_LEFT + for i_col in range(2, len(tb.columns) - 1, 2): + tb.columns.alignment[i_col] = bt.ALIGN_RIGHT + tb.columns.padding_left[i_col] = 2 + tb.columns.padding_right[i_col] = 0 + tb.columns.padding_left[i_col + 1] = 0 + + if oneline: + # Render a copy of the table, with no spacing and doctored columns. + tb2 = bt.BeautifulTable() + column_headers = tb.column_headers[:] + column_headers[0] = "" + tb2.column_headers = column_headers + tb2.rows.append(tb2.columns.header) + # tb2.set_style(bt.STYLE_COMPACT) + tb2.set_style(bt.STYLE_NONE) + tb2.maxwidth = 9999 + tb2.columns.alignment = tb.columns.alignment + tb2.columns.padding_left = 0 + tb2.columns.padding_right = 0 + result = next(tb2._get_string()) + else: + # pre-render with no width limitation + tb.maxwidth = 9999 + str(tb) + # Force wraps in the 'value column' (== column #1) + widths = tb.columns.width[:] + widths[1] = 0 + widths[1] = max_width - sum(widths) + tb.columns.width = widths + tb.columns.width_exceed_policy = bt.WEP_WRAP + # Also must re-establish the style. + # Hmmm, none of this is that obvious, is it ?? + tb.set_style(bt.STYLE_NONE) + summary_lines = list(tb._get_string(recalculate_width=False)) + result = "\n".join(summary_lines) + return result + + def __str__(self): + # Return a full cube summary as the printed form. + return self.to_string() diff --git a/lib/iris/_representation/cube_summary.py b/lib/iris/_representation/cube_summary.py index 8169a73483..becae176db 100644 --- a/lib/iris/_representation/cube_summary.py +++ b/lib/iris/_representation/cube_summary.py @@ -197,6 +197,12 @@ def __init__(self, title, cell_methods): class CubeSummary: + """ + This class provides a structure for output representations of an Iris cube. + It is used 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 diff --git a/lib/iris/tests/unit/representation/test_cube_printout.py b/lib/iris/tests/unit/representation/test_cube_printout.py new file mode 100644 index 0000000000..5c90d2474d --- /dev/null +++ b/lib/iris/tests/unit/representation/test_cube_printout.py @@ -0,0 +1,61 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +"""Unit tests for the :mod:`iris._representation.cube_summary` module.""" + +import iris.tests as tests + +import numpy as np +import iris +from iris.coords import AuxCoord +from iris._representation import cube_summary as icr +import iris.tests.stock as istk + + +from iris._representation.cube_printout import CubePrinter + + +def test_cube(): + cube = istk.realistic_3d() + # print(cube) + rotlats_1d, rotlons_1d = ( + cube.coord("grid_latitude").points, + cube.coord("grid_longitude").points, + ) + rotlons_2d, rotlats_2d = np.meshgrid(rotlons_1d, rotlats_1d) + + cs = cube.coord_system() + trulons, trulats = iris.analysis.cartography.unrotate_pole( + rotlons_2d, + rotlats_2d, + cs.grid_north_pole_longitude, + cs.grid_north_pole_latitude, + ) + cube.add_aux_coord( + AuxCoord(trulons, standard_name="longitude", units="degrees"), (1, 2) + ) + cube.add_aux_coord( + AuxCoord(trulats, standard_name="latitude", units="degrees"), (1, 2) + ) + + cube.attributes[ + "history" + ] = "Exceedingly and annoying long message with many sentences. And more and more. And more and more." + + return cube + + +class TestCubePrintout(tests.IrisTest): + def test_basic(self): + cube = test_cube() + summ = icr.CubeSummary(cube) + printer = CubePrinter(summ) + print("full:") + print(cube) + print(printer.to_string()) + print("") + print("oneline:") + print(repr(cube)) + print(printer.to_string(oneline=True)) From 106eb7c092421c39ffc5d96caf06701025cab5a3 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Mon, 25 Jan 2021 15:37:41 +0000 Subject: [PATCH 10/19] Slight tidy + more comments. --- lib/iris/_representation/cube_printout.py | 136 +++++++++++++--------- 1 file changed, 82 insertions(+), 54 deletions(-) diff --git a/lib/iris/_representation/cube_printout.py b/lib/iris/_representation/cube_printout.py index ecb4ed6cdd..b7728f266e 100644 --- a/lib/iris/_representation/cube_printout.py +++ b/lib/iris/_representation/cube_printout.py @@ -60,11 +60,11 @@ def _make_table( # Construct a beautifultable to represent the cube-summary info. # # NOTE: although beautifultable is useful and provides a flexible output - # form, but its formatting features are not yet adequate to produce our + # form, its formatting features are not yet adequate to produce our # desired "standard cube summary" appearance : For that, we still need - # column spanning. + # column spanning (at least). # So a 'normal' cube summary is produced by "CubePrinter.to_string()", - # which relies on information *not* stored in the table. + # which must also use information *not* stored in the table. extra_indent = " " * n_indent_extra sect_indent = " " * n_indent_section item_indent = sect_indent + " " * n_indent_item @@ -81,16 +81,12 @@ def _make_table( n_dims = len(dim_names) assert len(cube_shape) == n_dims - # NOTE: this is of no interest, as it "decided" to make a list - # of "{dim-name}: {length}", but we don't actually use that. - # dimheader.contents - tb = bt.BeautifulTable(max_width=self.max_width) # First setup the columns - # - 3 initially for the column-1 content - # - 2 (name, length) for each dimension - # - + # - x1 @0 column-1 content : main title; headings; elements-names + # - x1 @1 "value" content (for scalar items) + # - x2n @2 (name, length) for each of n dimensions column_texts = [nameunits_string, ""] for dim_name, length in zip(dim_names, cube_shape): column_texts.append(f"{dim_name}:") @@ -117,9 +113,13 @@ def _make_table( column_texts = [""] * len(column_texts) column_texts[1] = extra_indent + extra_string - # Similar for scalar sections : different ones handle differently + # Record where the 'scalar' part starts. + self.i_first_scalar_row = len(tb.rows) + + # Similar for scalar sections for sect in summ.scalar_sections.values(): if sect.contents: + # Add a row for the "section title" text. sect_name = sect.title column_texts = [sect_indent + sect_name, ""] column_texts += [""] * (2 * n_dims) @@ -131,6 +131,8 @@ def add_scalar(name, value): column_texts += [""] * (2 * n_dims) tb.rows.append(column_texts) + # Add a row for each item + # NOTE: different section types handle differently if "scalar coordinate" in title: for item in sect.contents: add_scalar(item.name, item.content) @@ -138,7 +140,7 @@ def add_scalar(name, value): for title, value in zip(sect.names, sect.values): add_scalar(title, value) elif "scalar cell measure" in title or "cell method" in title: - # These ones are "just strings". + # These are just strings: nothing in the 'value' column. for name in sect.contents: add_scalar(name, "") else: @@ -147,6 +149,71 @@ def add_scalar(name, value): return tb + def _set_table_style(self, tb): + # Fix all the column widths and alignments + tb.maxwidth = 9999 # don't curtail or wrap *anything* (initially) + tb.columns.alignment[0] = bt.ALIGN_LEFT + tb.columns.alignment[1] = bt.ALIGN_LEFT + for i_col in range(2, len(tb.columns) - 1, 2): + tb.columns.alignment[i_col] = bt.ALIGN_RIGHT + tb.columns.padding_left[i_col] = 2 + tb.columns.padding_right[i_col] = 0 + tb.columns.padding_left[i_col + 1] = 0 + tb.set_style(bt.STYLE_NONE) + + def _oneline_printout(self): + # Make a copy of the table, with no spacing and doctored columns. + tb = bt.BeautifulTable() # use new table, as copy() is deprecated + + # Add column headers, with extra text modifications. + column_headers = self.table.column_headers[:] + column_headers[0] = "" + # Add semicolons as column spacers + for i_col in range(3, len(column_headers) - 1, 2): + column_headers[i_col] += ";" + # NOTE: it would be "nice" use `table.columns.separator` to do + # this, but bt doesn't currently support that : Setting it + # affects the header-underscore/separator line instead. + tb.column_headers = column_headers + # Add a single row matching the header (or nothing will print) + # ( as used inside bt.BeatifulTable._get_string() ). + tb.rows.append(tb.columns.header) + + # Set all the normal style options + self._set_table_style(tb) + + # Adjust the column paddings for minimal spacing. + tb.columns.padding_left = 0 + tb.columns.padding_right = 1 + + # Return only the top (header) line. + result = next(tb._get_string()) + return result + + def _full_multiline_summary(self, max_width): + # pre-render with no width limitation whatsoever. + tb = self.table + tb.maxwidth = 9999 + str(tb) + + # Force wraps in the 'value column' (== column #1) + widths = tb.columns.width[:] + widths[1] = 0 + widths[1] = max_width - sum(widths) + tb.columns.width = widths + tb.columns.width_exceed_policy = bt.WEP_WRAP + # Also must re-establish the style. + # Hmmm, none of this is that obvious, is it ?? + tb.set_style(bt.STYLE_NONE) + + # Finally, use _get_string to reprint *without* recalulting widths. + summary_lines = list(tb._get_string(recalculate_width=False)) + result = "\n".join(summary_lines) + + return result + def to_string(self, oneline=False, max_width=None): """ Produce a printable summary. @@ -165,51 +232,12 @@ def to_string(self, oneline=False, max_width=None): """ if max_width is None: max_width = self.max_width - tb = self.table - tb.set_style(bt.STYLE_COMPACT) - # Fix all the column widths and alignments - tb.maxwidth = 9999 # don't curtail or wrap *anything* - tb.columns.alignment[0] = bt.ALIGN_LEFT - tb.columns.alignment[1] = bt.ALIGN_LEFT - for i_col in range(2, len(tb.columns) - 1, 2): - tb.columns.alignment[i_col] = bt.ALIGN_RIGHT - tb.columns.padding_left[i_col] = 2 - tb.columns.padding_right[i_col] = 0 - tb.columns.padding_left[i_col + 1] = 0 if oneline: - # Render a copy of the table, with no spacing and doctored columns. - tb2 = bt.BeautifulTable() - column_headers = tb.column_headers[:] - column_headers[0] = "" - tb2.column_headers = column_headers - tb2.rows.append(tb2.columns.header) - # tb2.set_style(bt.STYLE_COMPACT) - tb2.set_style(bt.STYLE_NONE) - tb2.maxwidth = 9999 - tb2.columns.alignment = tb.columns.alignment - tb2.columns.padding_left = 0 - tb2.columns.padding_right = 0 - result = next(tb2._get_string()) + result = self._oneline_printout() else: - # pre-render with no width limitation - tb.maxwidth = 9999 - str(tb) - # Force wraps in the 'value column' (== column #1) - widths = tb.columns.width[:] - widths[1] = 0 - widths[1] = max_width - sum(widths) - tb.columns.width = widths - tb.columns.width_exceed_policy = bt.WEP_WRAP - # Also must re-establish the style. - # Hmmm, none of this is that obvious, is it ?? - tb.set_style(bt.STYLE_NONE) - summary_lines = list(tb._get_string(recalculate_width=False)) - result = "\n".join(summary_lines) + result = self._full_multiline_summary(max_width) + return result def __str__(self): From 6bef1282beb23f0c735a5e1bba0ebb4711a3a0c8 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Tue, 26 Jan 2021 09:11:26 +0000 Subject: [PATCH 11/19] More tidies; working on width, test with n extra dims. --- lib/iris/_representation/cube_printout.py | 145 +++++++++++++++--- .../unit/representation/test_cube_printout.py | 49 +++++- 2 files changed, 161 insertions(+), 33 deletions(-) diff --git a/lib/iris/_representation/cube_printout.py b/lib/iris/_representation/cube_printout.py index b7728f266e..a5de8941aa 100644 --- a/lib/iris/_representation/cube_printout.py +++ b/lib/iris/_representation/cube_printout.py @@ -45,20 +45,26 @@ def __init__(self, cube_summary, max_width=None): self.max_width = max_width # Create a table to produce the printouts. - self.table = self._make_table(cube_summary) - # NOTE: owing to some problems with column width control, this does not - # encode our 'max_width' : For now, that is implemented by special code + self.table = self._make_table(cube_summary, max_width) + # NOTE: although beautifultable is useful and provides a flexible output + # form, its formatting features are not yet adequate to produce our + # desired "standard cube summary" appearance. + # (It really needs column-spanning, at least). + # So +make_table the table is useful, it does not encode the whole of the + # state / object info to produce + # So the 'normal' cube summary is produced by "CubePrinter.to_string()", + # which must also use information *not* stored in the table. # in :meth:`to_string`. def _make_table( self, cube_summary, - n_indent_section=5, - n_indent_item=5, - n_indent_extra=5, + max_width, + n_indent_section=4, + n_indent_item=4, + n_indent_extra=4, ): - # Construct a beautifultable to represent the cube-summary info. - # + """Make a beautifultable representing the cube-summary.""" # NOTE: although beautifultable is useful and provides a flexible output # form, its formatting features are not yet adequate to produce our # desired "standard cube summary" appearance : For that, we still need @@ -81,7 +87,7 @@ def _make_table( n_dims = len(dim_names) assert len(cube_shape) == n_dims - tb = bt.BeautifulTable(max_width=self.max_width) + tb = bt.BeautifulTable(maxwidth=max_width) # First setup the columns # - x1 @0 column-1 content : main title; headings; elements-names @@ -143,27 +149,46 @@ def add_scalar(name, value): # These are just strings: nothing in the 'value' column. for name in sect.contents: add_scalar(name, "") + # elif "mesh" in title: + # for line in sect.contents() + # add_scalar(line, "") else: msg = f"Unknown section type : {type(sect)}" raise ValueError(msg) + # Setup our "standard" style options, which is important because the + # column alignment is very helpful to readability. + CubePrinter._set_table_style(tb) + # .. but adopt a 'normal' overall style showing the boxes. + tb.set_style(bt.STYLE_DEFAULT) return tb - def _set_table_style(self, tb): - # Fix all the column widths and alignments - tb.maxwidth = 9999 # don't curtail or wrap *anything* (initially) + @staticmethod + def _set_table_style(tb, no_values_column=False): + # Fix all the column paddings and alignments. + # tb.maxwidth = 9999 # don't curtail or wrap *anything* (initially) tb.columns.alignment[0] = bt.ALIGN_LEFT - tb.columns.alignment[1] = bt.ALIGN_LEFT - for i_col in range(2, len(tb.columns) - 1, 2): + if no_values_column: + # Columns are: 1*(section/entry) + 2*(dim, dim-length) + dim_cols = range(1, len(tb.columns) - 1, 2) + else: + # Columns are: 1*(section/entry) + 1*(value) + 2*(dim, dim-length) + tb.columns.alignment[1] = bt.ALIGN_LEFT + dim_cols = range(2, len(tb.columns) - 1, 2) + for i_col in dim_cols: tb.columns.alignment[i_col] = bt.ALIGN_RIGHT + tb.columns.alignment[i_col] = bt.ALIGN_LEFT tb.columns.padding_left[i_col] = 2 tb.columns.padding_right[i_col] = 0 tb.columns.padding_left[i_col + 1] = 0 + + # Default style uses no decoration at all. tb.set_style(bt.STYLE_NONE) - def _oneline_printout(self): + def _oneline_string(self): + """Produce a one-line summary string.""" # Make a copy of the table, with no spacing and doctored columns. - tb = bt.BeautifulTable() # use new table, as copy() is deprecated + tb = bt.BeautifulTable() # start from a new table # Add column headers, with extra text modifications. column_headers = self.table.column_headers[:] @@ -177,22 +202,27 @@ def _oneline_printout(self): # this, but bt doesn't currently support that : Setting it # affects the header-underscore/separator line instead. tb.column_headers = column_headers - # Add a single row matching the header (or nothing will print) - # ( as used inside bt.BeatifulTable._get_string() ). + + # Add a single row matching the header (or nothing will print). + # -- as used inside bt.BeautifulTable._get_string(). tb.rows.append(tb.columns.header) - # Set all the normal style options - self._set_table_style(tb) + # Setup all our normal column styling options + CubePrinter._set_table_style(tb) - # Adjust the column paddings for minimal spacing. + # Adjust all column paddings for minimal spacing. tb.columns.padding_left = 0 tb.columns.padding_right = 1 + # Print with no width restriction + tb.maxwidth = 9999 + # Return only the top (header) line. result = next(tb._get_string()) return result - def _full_multiline_summary(self, max_width): + def _multiline_string_OLD(self, max_width): + """Produce a one-line summary string.""" # pre-render with no width limitation whatsoever. tb = self.table tb.maxwidth = 9999 @@ -214,6 +244,71 @@ def _full_multiline_summary(self, max_width): return result + def _multiline_summary(self, max_width): + """ + Produce a one-line summary string. + + Note: 'max_width' controls wrapping of the values column. but the + However, the sections-titles/item-names column and dim map are produced + *without* any width restriction. The max_width + + """ + # First print the vector sections. + + # Make a copy, but omitting column 1 (the scalar "values" column) + cols = list(self.table.columns.header) + del cols[1] + tb = bt.BeautifulTable() + tb.columns.header = cols + + # Copy vector rows only, removing column#1 (which should be blank) + # - which puts the dim-map columns in the column#1 place. + for i_row in range(self.i_first_scalar_row): + row = list(self.table.rows[i_row]) + del row[1] + tb.rows.append(row) + + # Establish our standard style settings (alignment etc). + self._set_table_style(tb, no_values_column=True) + + # Add parentheses around the dim column texts. + column_headers = tb.columns.header + column_headers[1] = "(" + column_headers[1] + column_headers[-1] = column_headers[-1] + ")" + tb.columns.header = column_headers + + # Use no width limitation. + tb.maxwidth = 9999 + # Use _get_string to fetch a list of lines. + summary_lines = list(tb._get_string()) + + # Now add the "scalar rows". + # For this part, we have only 2 columns + we force wrapping of the + # second column at a specific width. + + tb = self.table.rows[self.i_first_scalar_row :] + CubePrinter._set_table_style(tb) + # Pre-render with no width restriction, to pre-calculate widths + tb.maxwidth = 9999 + str(tb) + + # Force any wrapping needed in the 'value column' (== column #1) + widths = tb.columns.width[:] + widths[1] = max_width - widths[0] + # widths[2:] = 0 + tb.columns.width = widths + tb.columns.width_exceed_policy = bt.WEP_WRAP + + # Get rows for the scalar part + scalar_lines = tb._get_string(recalculate_width=False) + # discard first line (header) + next(scalar_lines) + # add the rest to the summary lines + summary_lines += list(scalar_lines) + + result = "\n".join(summary_lines) + return result + def to_string(self, oneline=False, max_width=None): """ Produce a printable summary. @@ -234,12 +329,12 @@ def to_string(self, oneline=False, max_width=None): max_width = self.max_width if oneline: - result = self._oneline_printout() + result = self._oneline_string() else: - result = self._full_multiline_summary(max_width) + result = self._multiline_summary(max_width) return result def __str__(self): - # Return a full cube summary as the printed form. + """Printout of self is the full multiline string.""" return self.to_string() diff --git a/lib/iris/tests/unit/representation/test_cube_printout.py b/lib/iris/tests/unit/representation/test_cube_printout.py index 5c90d2474d..4b6c6ab4ae 100644 --- a/lib/iris/tests/unit/representation/test_cube_printout.py +++ b/lib/iris/tests/unit/representation/test_cube_printout.py @@ -9,7 +9,8 @@ import numpy as np import iris -from iris.coords import AuxCoord +from iris.cube import Cube +from iris.coords import AuxCoord, DimCoord from iris._representation import cube_summary as icr import iris.tests.stock as istk @@ -17,9 +18,36 @@ from iris._representation.cube_printout import CubePrinter -def test_cube(): +def test_cube(n_extra_dims=0): cube = istk.realistic_3d() - # print(cube) + + # Add multiple extra dimensions to test the width controls + if n_extra_dims > 0: + new_dims = cube.shape + (1,) * n_extra_dims + new_data = cube.data.reshape(new_dims) + new_cube = Cube(new_data) + for i_dim in range(new_cube.ndim): + if i_dim < cube.ndim: + dimco = cube.coord(dimensions=i_dim, dim_coords=True) + else: + dim_name = "long_name_dim_{}".format(i_dim) + dimco = DimCoord([0], long_name=dim_name) + new_cube.add_dim_coord(dimco, i_dim) + + # Copy all aux coords too + for co, dims in cube._aux_coords_and_dims: + new_cube.add_aux_coord(co, dims) + + # Copy attributes + new_cube.attributes = cube.attributes + + # Nothing else to copy ? + assert not cube.ancillary_variables() + assert not cube.cell_measures() # Includes scalar ones + assert not cube.aux_factories + + cube = new_cube + rotlats_1d, rotlons_1d = ( cube.coord("grid_latitude").points, cube.coord("grid_longitude").points, @@ -49,13 +77,18 @@ def test_cube(): class TestCubePrintout(tests.IrisTest): def test_basic(self): - cube = test_cube() + cube = test_cube(n_extra_dims=4) summ = icr.CubeSummary(cube) - printer = CubePrinter(summ) - print("full:") + printer = CubePrinter(summ, max_width=110) + print("EXISTING full :") print(cube) - print(printer.to_string()) + print("---full--- :") + print(printer.to_string(max_width=80)) print("") - print("oneline:") + print("EXISTING oneline :") print(repr(cube)) + print("---oneline--- :") print(printer.to_string(oneline=True)) + print("") + print("original table form:") + print(printer.table) From 43ee0cd9f849d6104c824825753c5fe4e2f3b0b8 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Tue, 26 Jan 2021 10:24:55 +0000 Subject: [PATCH 12/19] Expand test cube to test all features (except scalar cube). --- .../unit/representation/test_cube_printout.py | 118 +++++++++++++----- 1 file changed, 89 insertions(+), 29 deletions(-) diff --git a/lib/iris/tests/unit/representation/test_cube_printout.py b/lib/iris/tests/unit/representation/test_cube_printout.py index 4b6c6ab4ae..293f2f55b3 100644 --- a/lib/iris/tests/unit/representation/test_cube_printout.py +++ b/lib/iris/tests/unit/representation/test_cube_printout.py @@ -9,45 +9,39 @@ import numpy as np import iris -from iris.cube import Cube from iris.coords import AuxCoord, DimCoord from iris._representation import cube_summary as icr import iris.tests.stock as istk - +from iris.util import new_axis from iris._representation.cube_printout import CubePrinter def test_cube(n_extra_dims=0): - cube = istk.realistic_3d() + # cube = istk.realistic_3d() + cube = istk.realistic_4d() # this one has a derived coord - # Add multiple extra dimensions to test the width controls + # Optionally : add multiple extra dimensions to test the width controls if n_extra_dims > 0: - new_dims = cube.shape + (1,) * n_extra_dims - new_data = cube.data.reshape(new_dims) - new_cube = Cube(new_data) - for i_dim in range(new_cube.ndim): - if i_dim < cube.ndim: - dimco = cube.coord(dimensions=i_dim, dim_coords=True) - else: - dim_name = "long_name_dim_{}".format(i_dim) - dimco = DimCoord([0], long_name=dim_name) - new_cube.add_dim_coord(dimco, i_dim) - - # Copy all aux coords too - for co, dims in cube._aux_coords_and_dims: - new_cube.add_aux_coord(co, dims) - - # Copy attributes - new_cube.attributes = cube.attributes - - # Nothing else to copy ? - assert not cube.ancillary_variables() - assert not cube.cell_measures() # Includes scalar ones - assert not cube.aux_factories + new_cube = cube.copy() + # Add n extra scalar *1 coords + for i_dim in range(n_extra_dims): + dim_name = "long_name_dim_{}".format(i_dim + cube.ndim) + dimco = DimCoord([0], long_name=dim_name) + new_cube.add_aux_coord(dimco) + # Promote to dim coord + new_cube = new_axis(new_cube, dim_name) + + # Put them all at the back + dim_order = list(range(new_cube.ndim)) + dim_order = dim_order[n_extra_dims:] + dim_order[:n_extra_dims] + new_cube.transpose(dim_order) # dontcha hate this inplace way ?? + + # Replace the original test cube cube = new_cube + # Add extra things to test all aspects rotlats_1d, rotlons_1d = ( cube.coord("grid_latitude").points, cube.coord("grid_longitude").points, @@ -61,23 +55,88 @@ def test_cube(n_extra_dims=0): cs.grid_north_pole_longitude, cs.grid_north_pole_latitude, ) + co_lat, co_lon = cube.coord(axis="y"), cube.coord(axis="x") + latlon_dims = cube.coord_dims(co_lat) + cube.coord_dims(co_lon) cube.add_aux_coord( - AuxCoord(trulons, standard_name="longitude", units="degrees"), (1, 2) + AuxCoord(trulons, standard_name="longitude", units="degrees"), + latlon_dims, ) cube.add_aux_coord( - AuxCoord(trulats, standard_name="latitude", units="degrees"), (1, 2) + AuxCoord(trulats, standard_name="latitude", units="degrees"), + latlon_dims, ) cube.attributes[ "history" ] = "Exceedingly and annoying long message with many sentences. And more and more. And more and more." + cube.add_cell_method(iris.coords.CellMethod("mean", ["time"])) + cube.add_cell_method( + iris.coords.CellMethod( + "max", ["latitude"], intervals="3 hour", comments="remark" + ) + ) + latlons_shape = [cube.shape[i_dim] for i_dim in latlon_dims] + cube.add_cell_measure( + iris.coords.CellMeasure( + np.zeros(latlons_shape), long_name="cell-timings", units="s" + ), + latlon_dims, + ) + cube.add_cell_measure( + iris.coords.CellMeasure( + [4.3], long_name="whole_cell_factor", units="m^2" + ), + (), + ) # a SCALAR cell-measure + + time_dim = cube.coord_dims(cube.coord(axis="t")) + cube.add_ancillary_variable( + iris.coords.AncillaryVariable( + np.zeros(cube.shape[0]), long_name="time_scalings", units="ppm" + ), + time_dim, + ) + cube.add_ancillary_variable( + iris.coords.AncillaryVariable( + [3.2], long_name="whole_cube_area_factor", units="m^2" + ), + (), + ) # a SCALAR ancillary + + # Add some duplicate-named coords (not possible for dim-coords) + vector_duplicate_name = "level_height" + co_orig = cube.coord(vector_duplicate_name) + dim_orig = cube.coord_dims(co_orig) + co_new = co_orig.copy() + co_new.attributes.update(dict(extra_distinguishing="this", a=1, b=2)) + cube.add_aux_coord(co_new, dim_orig) + + vector_different_name = "sigma" + co_orig = cube.coord(vector_different_name) + co_orig.attributes["setting"] = "a" + dim_orig = cube.coord_dims(co_orig) + co_new = co_orig.copy() + co_new.attributes["setting"] = "B" + cube.add_aux_coord(co_new, dim_orig) + + # Also need to test this with a SCALAR coord + scalar_duplicate_name = "forecast_period" + co_orig = cube.coord(scalar_duplicate_name) + co_new = co_orig.copy() + co_new.points = co_new.points + 2.3 + co_new.attributes["different"] = "True" + cube.add_aux_coord(co_new) + return cube class TestCubePrintout(tests.IrisTest): def test_basic(self): - cube = test_cube(n_extra_dims=4) + cube = test_cube( + n_extra_dims=4 + ) # NB does not yet work with factories. + # cube = test_cube() summ = icr.CubeSummary(cube) printer = CubePrinter(summ, max_width=110) print("EXISTING full :") @@ -92,3 +151,4 @@ def test_basic(self): print("") print("original table form:") print(printer.table) + print("") From 70f02c2281f321bc8c9c33650decde22b1c8ef88 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Tue, 26 Jan 2021 14:05:03 +0000 Subject: [PATCH 13/19] Support 'extra' info for same-named coords. --- lib/iris/_representation/cube_printout.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/iris/_representation/cube_printout.py b/lib/iris/_representation/cube_printout.py index a5de8941aa..0bdfa821c6 100644 --- a/lib/iris/_representation/cube_printout.py +++ b/lib/iris/_representation/cube_printout.py @@ -71,9 +71,10 @@ def _make_table( # column spanning (at least). # So a 'normal' cube summary is produced by "CubePrinter.to_string()", # which must also use information *not* stored in the table. - extra_indent = " " * n_indent_extra sect_indent = " " * n_indent_section item_indent = sect_indent + " " * n_indent_item + item_to_extra_indent = " " * n_indent_extra + extra_indent = item_indent + item_to_extra_indent summ = cube_summary fullheader = summ.header @@ -117,7 +118,8 @@ def _make_table( tb.rows.append(column_texts) if extra_string: column_texts = [""] * len(column_texts) - column_texts[1] = extra_indent + extra_string + column_texts[0] = extra_indent + extra_string + tb.rows.append(column_texts) # Record where the 'scalar' part starts. self.i_first_scalar_row = len(tb.rows) @@ -132,7 +134,7 @@ def _make_table( tb.rows.append(column_texts) title = sect_name.lower() - def add_scalar(name, value): + def add_scalar(name, value=""): column_texts = [item_indent + name, value] column_texts += [""] * (2 * n_dims) tb.rows.append(column_texts) @@ -142,13 +144,15 @@ def add_scalar(name, value): if "scalar coordinate" in title: for item in sect.contents: add_scalar(item.name, item.content) + if item.extra: + add_scalar(item_to_extra_indent + item.extra) elif "attribute" in title: for title, value in zip(sect.names, sect.values): add_scalar(title, value) elif "scalar cell measure" in title or "cell method" in title: # These are just strings: nothing in the 'value' column. for name in sect.contents: - add_scalar(name, "") + add_scalar(name) # elif "mesh" in title: # for line in sect.contents() # add_scalar(line, "") From f09a36f78e2d3a976b2c84ce7eba824e3ce124b8 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Tue, 26 Jan 2021 18:35:18 +0000 Subject: [PATCH 14/19] Add derived, scalar cubes, fix widths over *whole* of col#0. --- lib/iris/_representation/cube_printout.py | 93 ++++++++++++------- .../unit/representation/test_cube_printout.py | 38 ++++++-- 2 files changed, 90 insertions(+), 41 deletions(-) diff --git a/lib/iris/_representation/cube_printout.py b/lib/iris/_representation/cube_printout.py index 0bdfa821c6..ab73ec8249 100644 --- a/lib/iris/_representation/cube_printout.py +++ b/lib/iris/_representation/cube_printout.py @@ -81,7 +81,6 @@ def _make_table( nameunits_string = fullheader.nameunit dimheader = fullheader.dimension_header cube_is_scalar = dimheader.scalar - assert not cube_is_scalar # Just for now... cube_shape = dimheader.shape # may be empty dim_names = dimheader.dim_names # may be empty @@ -95,10 +94,16 @@ def _make_table( # - x1 @1 "value" content (for scalar items) # - x2n @2 (name, length) for each of n dimensions column_texts = [nameunits_string, ""] - for dim_name, length in zip(dim_names, cube_shape): - column_texts.append(f"{dim_name}:") - column_texts.append(f"{length:d}") + if cube_is_scalar: + # We will put this in the column-2 position (replacing the dim-map) + column_texts.append("(scalar cube)") + else: + for dim_name, length in zip(dim_names, cube_shape): + column_texts.append(f"{dim_name}:") + column_texts.append(f"{length:d}") + + n_cols = len(column_texts) tb.columns.header = column_texts[:] # Making copy, in case (!) # Add rows from all the vector sections @@ -106,7 +111,7 @@ def _make_table( if sect.contents: sect_name = sect.title column_texts = [sect_indent + sect_name, ""] - column_texts += [""] * (2 * n_dims) + column_texts += [""] * (n_cols - 2) tb.rows.append(column_texts) for vec_summary in sect.contents: element_name = vec_summary.name @@ -115,6 +120,7 @@ def _make_table( column_texts = [item_indent + element_name, ""] for dim_char in dim_chars: column_texts += ["", dim_char] + column_texts += [""] * (n_cols - len(column_texts)) tb.rows.append(column_texts) if extra_string: column_texts = [""] * len(column_texts) @@ -130,13 +136,13 @@ def _make_table( # Add a row for the "section title" text. sect_name = sect.title column_texts = [sect_indent + sect_name, ""] - column_texts += [""] * (2 * n_dims) + column_texts += [""] * (n_cols - 2) tb.rows.append(column_texts) title = sect_name.lower() def add_scalar(name, value=""): column_texts = [item_indent + name, value] - column_texts += [""] * (2 * n_dims) + column_texts += [""] * (n_cols - 2) tb.rows.append(column_texts) # Add a row for each item @@ -195,17 +201,24 @@ def _oneline_string(self): tb = bt.BeautifulTable() # start from a new table # Add column headers, with extra text modifications. - column_headers = self.table.column_headers[:] - column_headers[0] = "" + cols = self.table.column_headers[:] + # Add parentheses around the dim column texts, unless already present + # - e.g. "(scalar cube)". + if len(cols) > 2 and not cols[2].startswith("("): + # Add parentheses around the dim columns + cols[2] = "(" + cols[2] + cols[-1] = cols[-1] + ")" + + # Add <> context. + cols[0] = "" # Add semicolons as column spacers - for i_col in range(3, len(column_headers) - 1, 2): - column_headers[i_col] += ";" + for i_col in range(3, len(cols) - 1, 2): + cols[i_col] += ";" # NOTE: it would be "nice" use `table.columns.separator` to do # this, but bt doesn't currently support that : Setting it # affects the header-underscore/separator line instead. - tb.column_headers = column_headers + tb.column_headers = cols # Add a single row matching the header (or nothing will print). # -- as used inside bt.BeautifulTable._get_string(). @@ -263,11 +276,19 @@ def _multiline_summary(self, max_width): cols = list(self.table.columns.header) del cols[1] tb = bt.BeautifulTable() + + # Add parentheses around the dim column texts, unless already present + # - e.g. "(scalar cube)". + if len(cols) > 1 and not cols[1].startswith("("): + # Add parentheses around the dim columns + cols[1] = "(" + cols[1] + cols[-1] = cols[-1] + ")" + tb.columns.header = cols - # Copy vector rows only, removing column#1 (which should be blank) + # Copy the rows, also removing column#1 throughout # - which puts the dim-map columns in the column#1 place. - for i_row in range(self.i_first_scalar_row): + for i_row in range(len(self.table.rows)): row = list(self.table.rows[i_row]) del row[1] tb.rows.append(row) @@ -275,32 +296,42 @@ def _multiline_summary(self, max_width): # Establish our standard style settings (alignment etc). self._set_table_style(tb, no_values_column=True) - # Add parentheses around the dim column texts. - column_headers = tb.columns.header - column_headers[1] = "(" + column_headers[1] - column_headers[-1] = column_headers[-1] + ")" - tb.columns.header = column_headers - # Use no width limitation. tb.maxwidth = 9999 - # Use _get_string to fetch a list of lines. - summary_lines = list(tb._get_string()) + # First "pre-render", to calculate widths with all of column 0, to + # account for any long scalar names + str(tb) + # Capture the column widths for later + # WARNING: "table.columns.width" is not a simple list, but this works.. + column_widths = list(tb.columns.width[:]) + + # Get just the 'vector rows', as a list of strings + tb = tb.rows[: self.i_first_scalar_row] + if len(tb.rows) > 0: + # 'Normal' case with vector rows : _get_string--> list of lines + summary_lines = list(tb._get_string(recalculate_width=False)) + else: + # When there *are* no vector rows.. + # add a 'dummy' row and get just the first line (= the header) + # ( N.B. as done in bt code table._get_string ). + tb.rows.append(tb.columns.header) + summary_lines = [next(tb._get_string(recalculate_width=False))] # Now add the "scalar rows". # For this part, we have only 2 columns + we force wrapping of the # second column at a specific width. tb = self.table.rows[self.i_first_scalar_row :] + # Reset style : *needed* for derived table -- really not obvious ?!? CubePrinter._set_table_style(tb) - # Pre-render with no width restriction, to pre-calculate widths - tb.maxwidth = 9999 - str(tb) # Force any wrapping needed in the 'value column' (== column #1) - widths = tb.columns.width[:] - widths[1] = max_width - widths[0] - # widths[2:] = 0 - tb.columns.width = widths + # WARNING: the 'table.columns.width' parameter behaves strangely : + # - you cannot always simply assign an iterable ? + tb.columns.width[0] = column_widths[0] + tb.columns.width[1] = max_width - column_widths[0] + for i in range(2, len(column_widths)): + column_widths[i] = 0 tb.columns.width_exceed_policy = bt.WEP_WRAP # Get rows for the scalar part diff --git a/lib/iris/tests/unit/representation/test_cube_printout.py b/lib/iris/tests/unit/representation/test_cube_printout.py index 293f2f55b3..102dac8791 100644 --- a/lib/iris/tests/unit/representation/test_cube_printout.py +++ b/lib/iris/tests/unit/representation/test_cube_printout.py @@ -18,7 +18,6 @@ def test_cube(n_extra_dims=0): - # cube = istk.realistic_3d() cube = istk.realistic_4d() # this one has a derived coord # Optionally : add multiple extra dimensions to test the width controls @@ -109,7 +108,7 @@ def test_cube(n_extra_dims=0): co_orig = cube.coord(vector_duplicate_name) dim_orig = cube.coord_dims(co_orig) co_new = co_orig.copy() - co_new.attributes.update(dict(extra_distinguishing="this", a=1, b=2)) + co_new.attributes.update(dict(a=1, b=2)) cube.add_aux_coord(co_new, dim_orig) vector_different_name = "sigma" @@ -128,21 +127,27 @@ def test_cube(n_extra_dims=0): co_new.attributes["different"] = "True" cube.add_aux_coord(co_new) + # Add a scalar coord with a *really* long name, to challenge the column width formatting + long_name = "long_long_long_long_long_long_long_long_long_long_long_name" + cube.add_aux_coord(DimCoord([0], long_name=long_name)) return cube class TestCubePrintout(tests.IrisTest): - def test_basic(self): - cube = test_cube( - n_extra_dims=4 - ) # NB does not yet work with factories. - # cube = test_cube() + def _exercise_methods(self, cube): summ = icr.CubeSummary(cube) printer = CubePrinter(summ, max_width=110) + has_scalar_ancils = any( + len(anc.cube_dims(cube)) == 0 for anc in cube.ancillary_variables() + ) + unprintable = has_scalar_ancils and cube.ndim == 0 print("EXISTING full :") - print(cube) + if unprintable: + print(" ( would fail, due to scalar-cube with scalar-ancils )") + else: + print(cube) print("---full--- :") - print(printer.to_string(max_width=80)) + print(printer.to_string(max_width=120)) print("") print("EXISTING oneline :") print(repr(cube)) @@ -150,5 +155,18 @@ def test_basic(self): print(printer.to_string(oneline=True)) print("") print("original table form:") - print(printer.table) + tb = printer.table + tb.maxwidth = 140 + print(tb) + print("") print("") + + def test_basic(self): + cube = test_cube( + n_extra_dims=4 + ) # NB does not yet work with factories. + self._exercise_methods(cube) + + def test_scalar_cube(self): + cube = test_cube()[0, 0, 0, 0] + self._exercise_methods(cube) From 2094d321a0541b829cef0ac89930d50dc72b23f3 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Tue, 26 Jan 2021 23:54:38 +0000 Subject: [PATCH 15/19] Correct usage to avoid warnings. --- lib/iris/_representation/cube_printout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/iris/_representation/cube_printout.py b/lib/iris/_representation/cube_printout.py index ab73ec8249..6cadef1724 100644 --- a/lib/iris/_representation/cube_printout.py +++ b/lib/iris/_representation/cube_printout.py @@ -201,7 +201,7 @@ def _oneline_string(self): tb = bt.BeautifulTable() # start from a new table # Add column headers, with extra text modifications. - cols = self.table.column_headers[:] + cols = self.table.columns.header[:] # Add parentheses around the dim column texts, unless already present # - e.g. "(scalar cube)". if len(cols) > 2 and not cols[2].startswith("("): @@ -218,7 +218,7 @@ def _oneline_string(self): # NOTE: it would be "nice" use `table.columns.separator` to do # this, but bt doesn't currently support that : Setting it # affects the header-underscore/separator line instead. - tb.column_headers = cols + tb.columns.header = cols # Add a single row matching the header (or nothing will print). # -- as used inside bt.BeautifulTable._get_string(). From a284ddeed76f9c7a35e193b31d695c7386c8e0c3 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Wed, 27 Jan 2021 09:06:47 +0000 Subject: [PATCH 16/19] Fix printout for no scalar content. --- lib/iris/_representation/cube_printout.py | 37 ++++++++++++----------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/lib/iris/_representation/cube_printout.py b/lib/iris/_representation/cube_printout.py index 6cadef1724..869d18407f 100644 --- a/lib/iris/_representation/cube_printout.py +++ b/lib/iris/_representation/cube_printout.py @@ -322,24 +322,25 @@ def _multiline_summary(self, max_width): # second column at a specific width. tb = self.table.rows[self.i_first_scalar_row :] - # Reset style : *needed* for derived table -- really not obvious ?!? - CubePrinter._set_table_style(tb) - - # Force any wrapping needed in the 'value column' (== column #1) - # WARNING: the 'table.columns.width' parameter behaves strangely : - # - you cannot always simply assign an iterable ? - tb.columns.width[0] = column_widths[0] - tb.columns.width[1] = max_width - column_widths[0] - for i in range(2, len(column_widths)): - column_widths[i] = 0 - tb.columns.width_exceed_policy = bt.WEP_WRAP - - # Get rows for the scalar part - scalar_lines = tb._get_string(recalculate_width=False) - # discard first line (header) - next(scalar_lines) - # add the rest to the summary lines - summary_lines += list(scalar_lines) + if tb.rows: + # Reset style : *needed* for derived table -- really not obvious ?!? + CubePrinter._set_table_style(tb) + + # Force any wrapping needed in the 'value column' (== column #1) + # WARNING: the 'table.columns.width' parameter behaves strangely : + # - you cannot always simply assign an iterable ? + tb.columns.width[0] = column_widths[0] + tb.columns.width[1] = max_width - column_widths[0] + for i in range(2, len(column_widths)): + column_widths[i] = 0 + tb.columns.width_exceed_policy = bt.WEP_WRAP + + # Get rows for the scalar part + scalar_lines = tb._get_string(recalculate_width=False) + # discard first line (header) + next(scalar_lines) + # add the rest to the summary lines + summary_lines += list(scalar_lines) result = "\n".join(summary_lines) return result From 9f7689421391e0f246e524f39e907047ebd47a81 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Wed, 27 Jan 2021 09:09:30 +0000 Subject: [PATCH 17/19] Plumb cube summary and string reprs into new code. --- lib/iris/cube.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/iris/cube.py b/lib/iris/cube.py index c3f77c9288..6be63f58d6 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -49,7 +49,8 @@ import iris.coords import iris.exceptions import iris.util - +from iris._representation.cube_summary import CubeSummary +from iris._representation.cube_printout import CubePrinter __all__ = ["Cube", "CubeList"] @@ -2213,6 +2214,14 @@ def _summary_extra(self, coords, summary, indent): return new_summary def summary(self, shorten=False, name_padding=35): + result = CubePrinter(CubeSummary(self)) + if shorten: + # CubePrinter init doesn't have a "oneline" control, so we return + # a different type in this case. + result = result.to_string(oneline=True) + return result + + def summary_OLDSTYLE(self, shorten=False, name_padding=35): """ Unicode string summary of the Cube with name, a list of dim coord names versus length and optionally relevant coordinate information. @@ -2591,15 +2600,13 @@ def vector_summary( return summary def __str__(self): - return self.summary() + return self.summary().to_string() def __unicode__(self): - return self.summary() + return self.summary().to_string() def __repr__(self): - return "" % self.summary( - shorten=True, name_padding=1 - ) + return self.summary().to_string(oneline=True) def _repr_html_(self): from iris.experimental.representation import CubeRepresentation From e58229799be5de375a38b185b137506e19ac8fbe Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Wed, 27 Jan 2021 10:13:07 +0000 Subject: [PATCH 18/19] Add beautifultable to dependencies. --- requirements/ci/py36.yml | 1 + requirements/ci/py37.yml | 1 + requirements/core.txt | 1 + 3 files changed, 3 insertions(+) diff --git a/requirements/ci/py36.yml b/requirements/ci/py36.yml index 4d9d25d7c6..0bea330cdb 100644 --- a/requirements/ci/py36.yml +++ b/requirements/ci/py36.yml @@ -20,6 +20,7 @@ dependencies: - numpy>=1.14 - python-xxhash - scipy + - beautifultable # Optional dependencies. - esmpy>=7.0 diff --git a/requirements/ci/py37.yml b/requirements/ci/py37.yml index bdb097796a..a58c8ac197 100644 --- a/requirements/ci/py37.yml +++ b/requirements/ci/py37.yml @@ -20,6 +20,7 @@ dependencies: - numpy>=1.14 - python-xxhash - scipy + - beautifultable # Optional dependencies. - esmpy>=7.0 diff --git a/requirements/core.txt b/requirements/core.txt index 9e0c4fb1bb..3fed187585 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -9,3 +9,4 @@ netcdf4 numpy>=1.14 scipy xxhash +beautifultable From ac82f87018b6b15221b0ef50be27d532e425a315 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Thu, 28 Jan 2021 09:11:52 +0000 Subject: [PATCH 19/19] Use pip to install beautifultable --- requirements/ci/py36.yml | 4 +++- requirements/ci/py37.yml | 4 +++- requirements/core.txt | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/requirements/ci/py36.yml b/requirements/ci/py36.yml index 0bea330cdb..f89ef10382 100644 --- a/requirements/ci/py36.yml +++ b/requirements/ci/py36.yml @@ -20,7 +20,9 @@ dependencies: - numpy>=1.14 - python-xxhash - scipy - - beautifultable + - pip + - pip: + - beautifultable>=1 # Optional dependencies. - esmpy>=7.0 diff --git a/requirements/ci/py37.yml b/requirements/ci/py37.yml index a58c8ac197..f6f9347b88 100644 --- a/requirements/ci/py37.yml +++ b/requirements/ci/py37.yml @@ -20,7 +20,9 @@ dependencies: - numpy>=1.14 - python-xxhash - scipy - - beautifultable + - pip + - pip: + - beautifultable>=1 # Optional dependencies. - esmpy>=7.0 diff --git a/requirements/core.txt b/requirements/core.txt index 3fed187585..14bb81c863 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -9,4 +9,4 @@ netcdf4 numpy>=1.14 scipy xxhash -beautifultable +beautifultable>=1