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/cube_printout.py b/lib/iris/_representation/cube_printout.py new file mode 100644 index 0000000000..0c7cfeec09 --- /dev/null +++ b/lib/iris/_representation/cube_printout.py @@ -0,0 +1,365 @@ +# 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. + +""" +from copy import deepcopy + +from iris._representation.cube_summary import CubeSummary + + +class Table: + """ + A container of text strings in rows + columns, that can format its content + into a string per row, with contents in columns of fixed width. + + Supports left- or right- aligned columns, alignment being set "per row". + A column may also be set, beyond which output is printed without further + formatting, and without affecting any subsequent column widths. + This is used as a crude alternative to column spanning. + + """ + + def __init__(self, rows=None, col_widths=None): + if rows is None: + rows = [] + self.rows = [deepcopy(row) for row in rows] + self.col_widths = col_widths + + def copy(self): + return Table(self.rows, col_widths=self.col_widths) + + @property + def n_columns(self): + if self.rows: + result = len(self.rows[0].cols) + else: + result = None + return result + + class Row: + """A set of column info, plus per-row formatting controls.""" + + def __init__(self, cols, aligns, i_col_unlimited=None): + assert len(cols) == len(aligns) + self.cols = cols + self.aligns = aligns + self.i_col_unlimited = i_col_unlimited + # This col + those after do not add to width + # - a crude alternative to proper column spanning + + def add_row(self, cols, aligns, i_col_unlimited=None): + """ + Create a new row at the bottom. + + Args: + * cols (list of string): + Per-column content. Length must match the other rows (if any). + * aligns (list of {'left', 'right'}): + Per-column alignments. Length must match 'cols'. + * i_col_unlimited (int or None): + Column beyond which content does not affect the column widths. + ( meaning contents will print without limit ). + + """ + n_cols = len(cols) + if len(aligns) != n_cols: + msg = ( + f"Number of aligns ({len(aligns)})" + f" != number of cols ({n_cols})" + ) + raise ValueError(msg) + if self.n_columns is not None: + # For now, all rows must have same number of columns + if n_cols != self.n_columns: + msg = ( + f"Number of columns ({n_cols})" + f" != existing table.n_columns ({self.n_columns})" + ) + raise ValueError(msg) + row = self.Row(cols, aligns, i_col_unlimited) + self.rows.append(row) + + def set_min_column_widths(self): + """Set all column widths to minimum required for current content.""" + if self.rows: + widths = [0] * self.n_columns + for row in self.rows: + cols, lim = row.cols, row.i_col_unlimited + if lim is not None: + cols = cols[:lim] # Ignore "unlimited" columns + for i_col, col in enumerate(cols): + widths[i_col] = max(widths[i_col], len(col)) + + self.col_widths = widths + + def formatted_as_strings(self): + """Return lines formatted to the set column widths.""" + if self.col_widths is None: + # If not set, calculate minimum widths. + self.set_min_column_widths() + result_lines = [] + for row in self.rows: + col_texts = [] + for col, align, width in zip( + row.cols, row.aligns, self.col_widths + ): + if align == "left": + col_text = col.ljust(width) + elif align == "right": + col_text = col.rjust(width) + else: + msg = ( + f'Unknown alignment "{align}" ' + 'not in ("left", "right")' + ) + raise ValueError(msg) + col_texts.append(col_text) + + row_line = " ".join(col_texts).rstrip() + result_lines.append(row_line) + return result_lines + + def __str__(self): + return "\n".join(self.formatted_as_strings()) + + +class CubePrinter: + """ + An object created from a + :class:`iris._representation.cube_or_summary.CubeSummary`, which provides + text printout of a :class:`iris.cube.Cube`. + + TODO: the cube :meth:`iris.cube.Cube.__str__` and + :meth:`iris.cube.Cube.__repr__` methods, and + :meth:`iris.cube.Cube.summary` with 'oneline=True', should use this to + produce cube summary strings. + + This class has no internal knowledge of :class:`iris.cube.Cube`, but only + of :class:`iris._representation.cube_or_summary.CubeSummary`. + + """ + + def __init__(self, cube_or_summary): + """ + An object that provides a printout of a cube. + + Args: + + * cube_or_summary (Cube or CubeSummary): + If a cube, first create a CubeSummary from it. + + + .. note:: + The CubePrinter is based on a digest of a CubeSummary, but does + not reference or store it. + + """ + # Create our internal table from the summary, to produce the printouts. + if isinstance(cube_or_summary, CubeSummary): + cube_summary = cube_or_summary + else: + cube_summary = CubeSummary(cube_or_summary) + self.table = self._ingest_summary(cube_summary) + + def _ingest_summary( + self, + cube_summary, + n_indent_section=4, + n_indent_item=4, + n_indent_extra=4, + ): + """Make a table of strings representing the cube-summary.""" + 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 + nameunits_string = fullheader.nameunit + dimheader = fullheader.dimension_header + cube_is_scalar = dimheader.scalar + + 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 + + # First setup the columns + # - x1 @0 column-1 content : main title; headings; elements-names + # - x1 @1 "value" content (for scalar items) + # - OR x2n @1.. (name, length) for each of n dimensions + column_header_texts = [nameunits_string] # Note extra spacer here + + if cube_is_scalar: + # We will put this in the column-1 position (replacing the dim-map) + column_header_texts.append("(scalar cube)") + else: + for dim_name, length in zip(dim_names, cube_shape): + column_header_texts.append(f"{dim_name}:") + column_header_texts.append(f"{length:d}") + + n_cols = len(column_header_texts) + + # Create a table : a (n_rows) list of (n_cols) strings + + table = Table() + + # Code for adding a row, with control options. + scalar_column_aligns = ["left"] * n_cols + vector_column_aligns = deepcopy(scalar_column_aligns) + if cube_is_scalar: + vector_column_aligns[1] = "left" + else: + vector_column_aligns[1:] = n_dims * ["right", "left"] + + def add_row(col_texts, scalar=False): + aligns = scalar_column_aligns if scalar else vector_column_aligns + i_col_unlimited = 1 if scalar else None + n_missing = n_cols - len(col_texts) + col_texts += [" "] * n_missing + table.add_row(col_texts, aligns, i_col_unlimited=i_col_unlimited) + + # Start with the header line + add_row(column_header_texts) + + # 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] + add_row(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] + add_row(column_texts) + if extra_string: + column_texts = [extra_indent + extra_string] + add_row(column_texts) + + # 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 + add_row([sect_indent + sect_name]) + + def add_scalar_row(name, value=""): + column_texts = [item_indent + name, value] + add_row(column_texts, scalar=True) + + # Add a row for each item + # NOTE: different section types need different handling + title = sect_name.lower() + if "scalar coordinate" in title: + for item in sect.contents: + add_scalar_row(item.name, item.content) + if item.extra: + add_scalar_row(item_to_extra_indent + item.extra) + elif "attribute" in title: + for title, value in zip(sect.names, sect.values): + add_scalar_row(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_row(name) + else: + msg = f"Unknown section type : {type(sect)}" + raise ValueError(msg) + + return table + + @staticmethod + def _decorated_table(table, name_padding=None): + """ + Return a modified table with added characters in the header. + + Note: 'name_padding' sets a minimum width for the name column (#0). + + """ + + # Copy the input table + extract the header + its columns. + table = table.copy() + header = table.rows[0] + cols = header.cols + + if name_padding: + # Extend header column#0 to a given minimum width. + cols[0] = cols[0].ljust(name_padding) + + # 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] + ")" + + # Add semicolons as dim column spacers + for i_col in range(2, len(cols) - 1, 2): + cols[i_col] += ";" + + # Modify the new table to be returned, invalidate any stored widths. + header.cols = cols + table.rows[0] = header + + # Recalc widths + table.set_min_column_widths() + + return table + + def _oneline_string(self): + """Produce a one-line summary string.""" + # Copy existing content -- just the header line. + table = Table(rows=[self.table.rows[0]]) + # Note: by excluding other columns, we get a minimum-width result. + + # Add standard decorations. + table = self._decorated_table(table, name_padding=0) + + # Format (with no extra spacing) --> one-line result + (oneline_result,) = table.formatted_as_strings() + return oneline_result + + def _multiline_summary(self, name_padding): + """Produce a multi-line summary string.""" + # Get a derived table with standard 'decorations' added. + table = self._decorated_table(self.table, name_padding=name_padding) + result_lines = table.formatted_as_strings() + result = "\n".join(result_lines) + return result + + def to_string(self, oneline=False, name_padding=35): + """ + 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. + * name_padding (int): + The minimum width for the "name" (#0) column. + Used for multiline output only. + + Returns: + result (string) + + """ + if oneline: + result = self._oneline_string() + else: + result = self._multiline_summary(name_padding) + + return result + + def __str__(self): + """Printout of self, as a full multiline string.""" + return self.to_string() 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/cube_printout/__init__.py b/lib/iris/tests/unit/representation/cube_printout/__init__.py new file mode 100644 index 0000000000..50ab3f8e45 --- /dev/null +++ b/lib/iris/tests/unit/representation/cube_printout/__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.cube_printout` module.""" diff --git a/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py new file mode 100644 index 0000000000..0fc3d02b03 --- /dev/null +++ b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py @@ -0,0 +1,464 @@ +# 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 :class:`iris._representation.cube_printout.CubePrintout`.""" +import iris.tests as tests + +import numpy as np + +from iris.cube import Cube +from iris.coords import ( + AuxCoord, + DimCoord, + AncillaryVariable, + CellMeasure, + CellMethod, +) +from iris._representation.cube_summary import CubeSummary + +from iris._representation.cube_printout import CubePrinter + + +class TestCubePrintout___str__(tests.IrisTest): + def test_str(self): + # Just check that its str representation is the 'to_string' result. + cube = Cube(0) + printer = CubePrinter(CubeSummary(cube)) + result = str(printer) + self.assertEqual(result, printer.to_string()) + + +def cube_replines(cube, **kwargs): + return CubePrinter(cube).to_string(**kwargs).split("\n") + + +class TestCubePrintout__to_string(tests.IrisTest): + def test_empty(self): + cube = Cube([0]) + rep = cube_replines(cube) + self.assertEqual(rep, ["unknown / (unknown) (-- : 1)"]) + rep = cube_replines(cube, oneline=True) + self.assertEqual(rep, ["unknown / (unknown) (-- : 1)"]) + + def test_scalar_cube_summaries(self): + cube = Cube(0) + rep = cube_replines(cube) + self.assertEqual( + rep, ["unknown / (unknown) (scalar cube)"] + ) + rep = cube_replines(cube, oneline=True) + self.assertEqual(rep, ["unknown / (unknown) (scalar cube)"]) + + def test_name_padding(self): + cube = Cube([1, 2], long_name="cube_accel", units="ms-2") + rep = cube_replines(cube) + self.assertEqual(rep, ["cube_accel / (ms-2) (-- : 2)"]) + rep = cube_replines(cube, name_padding=0) + self.assertEqual(rep, ["cube_accel / (ms-2) (-- : 2)"]) + rep = cube_replines(cube, name_padding=25) + self.assertEqual(rep, ["cube_accel / (ms-2) (-- : 2)"]) + + def test_columns_long_coordname(self): + cube = Cube([0], long_name="short", units=1) + coord = AuxCoord( + [0], long_name="very_very_very_very_very_long_coord_name" + ) + cube.add_aux_coord(coord, 0) + rep = cube_replines(cube) + expected = [ + "short / (1) (-- : 1)", + " Auxiliary coordinates:", + " very_very_very_very_very_long_coord_name x", + ] + self.assertEqual(rep, expected) + rep = cube_replines(cube, oneline=True) + self.assertEqual(rep, ["short / (1) (-- : 1)"]) + + def test_columns_long_attribute(self): + cube = Cube([0], long_name="short", units=1) + cube.attributes[ + "very_very_very_very_very_long_name" + ] = "longish string extends beyond dim columns" + rep = cube_replines(cube) + expected = [ + "short / (1) (-- : 1)", + " Attributes:", + ( + " very_very_very_very_very_long_name " + ": longish string extends beyond dim columns" + ), + ] + self.assertEqual(rep, expected) + + def test_coord_distinguishing_attributes(self): + # Printout of differing attributes to differentiate same-named coords. + # include : vector + scalar + cube = Cube([0, 1], long_name="name", units=1) + # Add a pair of vector coords with same name but different attributes. + cube.add_aux_coord( + AuxCoord([0, 1], long_name="co1", attributes=dict(a=1)), 0 + ) + cube.add_aux_coord( + AuxCoord([0, 1], long_name="co1", attributes=dict(a=2)), 0 + ) + # Likewise for scalar coords with same name but different attributes. + cube.add_aux_coord( + AuxCoord([0], long_name="co2", attributes=dict(a=10, b=12)) + ) + cube.add_aux_coord( + AuxCoord([1], long_name="co2", attributes=dict(a=10, b=11)) + ) + + rep = cube_replines(cube) + expected = [ + "name / (1) (-- : 2)", + " Auxiliary coordinates:", + " co1 x", + " a=1", + " co1 x", + " a=2", + " Scalar coordinates:", + " co2 0", + " b=12", + " co2 1", + " b=11", + ] + self.assertEqual(rep, expected) + + def test_coord_extra_attributes__array(self): + cube = Cube(0, long_name="name", units=1) + # Add a pair of vector coords with same name but different attributes. + array1 = np.arange(0, 3) + array2 = np.arange(10, 13) + cube.add_aux_coord( + AuxCoord([1.2], long_name="co1", attributes=dict(a=1, arr=array1)) + ) + cube.add_aux_coord( + AuxCoord([3.4], long_name="co1", attributes=dict(a=1, arr=array2)) + ) + + rep = cube_replines(cube) + expected = [ + "name / (1) (scalar cube)", + " Scalar coordinates:", + " co1 1.2", + " arr=array([0, 1, 2])", + " co1 3.4", + " arr=array([10, 11, 12])", + ] + self.assertEqual(rep, expected) + + def test_coord_extra_attributes__array__long(self): + # Also test with a long array representation. + # NOTE: this also pushes the dimension map right-wards. + array = 10 + np.arange(24.0).reshape((2, 3, 4)) + cube = Cube(0, long_name="name", units=1) + cube.add_aux_coord(AuxCoord([1], long_name="co")) + cube.add_aux_coord( + AuxCoord([2], long_name="co", attributes=dict(a=array + 1.0)) + ) + + rep = cube_replines(cube) + expected = [ + ( + "name / (1) " + " (scalar cube)" + ), + " Scalar coordinates:", + ( + " co " + " 1" + ), + ( + " co " + " 2" + ), + ( + " a=array([[[11., 12., 13., 14.], [15., 16., 17.," + " 18.], [19., 20., 21., 22.]],..." + ), + ] + self.assertEqual(rep, expected) + + def test_coord_extra_attributes__string(self): + cube = Cube(0, long_name="name", units=1) + cube.add_aux_coord(AuxCoord([1], long_name="co")) + cube.add_aux_coord( + AuxCoord( + [2], long_name="co", attributes=dict(note="string content") + ) + ) + rep = cube_replines(cube) + expected = [ + "name / (1) (scalar cube)", + " Scalar coordinates:", + " co 1", + " co 2", + " note='string content'", + ] + self.assertEqual(rep, expected) + + def test_coord_extra_attributes__string_escaped(self): + cube = Cube(0, long_name="name", units=1) + cube.add_aux_coord(AuxCoord([1], long_name="co")) + cube.add_aux_coord( + AuxCoord( + [2], + long_name="co", + attributes=dict(note="line 1\nline 2\tends."), + ) + ) + rep = cube_replines(cube) + expected = [ + "name / (1) (scalar cube)", + " Scalar coordinates:", + " co 1", + " co 2", + " note='line 1\\nline 2\\tends.'", + ] + self.assertEqual(rep, expected) + + def test_coord_extra_attributes__string_overlong(self): + cube = Cube(0, long_name="name", units=1) + cube.add_aux_coord(AuxCoord([1], long_name="co")) + long_string = ( + "this is very very very very very very very " + "very very very very very very very long." + ) + cube.add_aux_coord( + AuxCoord([2], long_name="co", attributes=dict(note=long_string)) + ) + rep = cube_replines(cube) + expected = [ + ( + "name / (1) " + " (scalar cube)" + ), + " Scalar coordinates:", + ( + " co " + " 1" + ), + ( + " co " + " 2" + ), + ( + " note='this is very very very very " + "very very very very very very very very..." + ), + ] + self.assertEqual(rep, expected) + + def test_section_vector_dimcoords(self): + cube = Cube(np.zeros((2, 3)), long_name="name", units=1) + cube.add_dim_coord(DimCoord([0, 1], long_name="y"), 0) + cube.add_dim_coord(DimCoord([0, 1, 2], long_name="x"), 1) + + rep = cube_replines(cube) + expected = [ + "name / (1) (y: 2; x: 3)", + " Dimension coordinates:", + " y x -", + " x - x", + ] + self.assertEqual(rep, expected) + + def test_section_vector_auxcoords(self): + cube = Cube(np.zeros((2, 3)), long_name="name", units=1) + cube.add_aux_coord(DimCoord([0, 1], long_name="y"), 0) + cube.add_aux_coord(DimCoord([0, 1, 2], long_name="x"), 1) + + rep = cube_replines(cube) + expected = [ + "name / (1) (-- : 2; -- : 3)", + " Auxiliary coordinates:", + " y x -", + " x - x", + ] + self.assertEqual(rep, expected) + + def test_section_vector_ancils(self): + cube = Cube(np.zeros((2, 3)), long_name="name", units=1) + cube.add_ancillary_variable( + AncillaryVariable([0, 1], long_name="av1"), 0 + ) + + rep = cube_replines(cube) + expected = [ + "name / (1) (-- : 2; -- : 3)", + " Ancillary variables:", + " av1 x -", + ] + self.assertEqual(rep, expected) + + def test_section_vector_cell_measures(self): + cube = Cube(np.zeros((2, 3)), long_name="name", units=1) + cube.add_cell_measure(CellMeasure([0, 1, 2], long_name="cm"), 1) + + rep = cube_replines(cube) + expected = [ + "name / (1) (-- : 2; -- : 3)", + " Cell measures:", + " cm - x", + ] + self.assertEqual(rep, expected) + + def test_section_scalar_coords(self): + # incl points + bounds + # TODO: ought to incorporate coord-based summary + # - which would allow for special printout of time values + cube = Cube([0], long_name="name", units=1) + cube.add_aux_coord(DimCoord([0.0], long_name="unbounded")) + cube.add_aux_coord(DimCoord([0], bounds=[[0, 7]], long_name="bounded")) + + rep = cube_replines(cube) + expected = [ + "name / (1) (-- : 1)", + " Scalar coordinates:", + " bounded 0, bound=(0, 7)", + " unbounded 0.0", + ] + self.assertEqual(rep, expected) + + def test_section_scalar_coords__string(self): + # incl a newline-escaped one + # incl a long (clipped) one + # CHECK THAT CLIPPED+ESCAPED WORKS (don't lose final quote) + cube = Cube([0], long_name="name", units=1) + cube.add_aux_coord(AuxCoord(["string-value"], long_name="text")) + long_string = ( + "A string value which is very very very very very very " + "very very very very very very very very long." + ) + cube.add_aux_coord( + AuxCoord([long_string], long_name="very_long_string") + ) + + rep = cube_replines(cube) + expected = [ + "name / (1) (-- : 1)", + " Scalar coordinates:", + " text string-value", + ( + " very_long_string A string value which is " + "very very very very very very very very very very..." + ), + ] + self.assertEqual(rep, expected) + + def test_section_scalar_cell_measures(self): + cube = Cube(np.zeros((2, 3)), long_name="name", units=1) + cube.add_cell_measure(CellMeasure([0], long_name="cm")) + + rep = cube_replines(cube) + expected = [ + "name / (1) (-- : 2; -- : 3)", + " Scalar cell measures:", + " cm", + ] + self.assertEqual(rep, expected) + + def test_section_scalar_ancillaries(self): + # There *is* no section for this. But there probably ought to be. + cube = Cube(np.zeros((2, 3)), long_name="name", units=1) + cube.add_ancillary_variable(AncillaryVariable([0], long_name="av")) + + rep = cube_replines(cube) + expected = [ + "name / (1) (-- : 2; -- : 3)", + " Ancillary variables:", + " av - -", + ] + self.assertEqual(rep, expected) + + def test_section_cube_attributes(self): + cube = Cube([0], long_name="name", units=1) + cube.attributes["number"] = 1.2 + cube.attributes["list"] = [3] + cube.attributes["string"] = "four five in a string" + cube.attributes["z_tupular"] = (6, (7, 8)) + rep = cube_replines(cube) + # NOTE: 'list' before 'number', as it uses "sorted(attrs.items())" + expected = [ + "name / (1) (-- : 1)", + " Attributes:", + " list : [3]", + " number : 1.2", + " string : four five in a string", + " z_tupular : (6, (7, 8))", + ] + self.assertEqual(rep, expected) + + def test_section_cube_attributes__string_extras(self): + cube = Cube([0], long_name="name", units=1) + # Overlong strings are truncated (with iris.util.clip_string). + long_string = ( + "this is very very very very very very very " + "very very very very very very very long." + ) + # Strings with embedded newlines or quotes are printed in quoted form. + cube.attributes["escaped"] = "escaped\tstring" + cube.attributes["long"] = long_string + cube.attributes["long_multi"] = "multi\nline, " + long_string + rep = cube_replines(cube) + expected = [ + "name / (1) (-- : 1)", + " Attributes:", + " escaped : 'escaped\\tstring'", + ( + " long : this is very very very " + "very very very very very very very very very very..." + ), + ( + " long_multi : 'multi\\nline, " + "this is very very very very very very very very very very..." + ), + ] + self.assertEqual(rep, expected) + + def test_section_cube_attributes__array(self): + # Including a long one, which gets a truncated representation. + cube = Cube([0], long_name="name", units=1) + small_array = np.array([1.2, 3.4]) + large_array = np.arange(36).reshape((18, 2)) + cube.attributes["array"] = small_array + cube.attributes["bigarray"] = large_array + rep = cube_replines(cube) + expected = [ + "name / (1) (-- : 1)", + " Attributes:", + " array : array([1.2, 3.4])", + ( + " bigarray : array([[ 0, 1], [ 2, 3], " + "[ 4, 5], [ 6, 7], [ 8, 9], [10, 11], [12, 13],..." + ), + ] + self.assertEqual(rep, expected) + + def test_section_cell_methods(self): + cube = Cube([0], long_name="name", units=1) + cube.add_cell_method(CellMethod("stdev", "area")) + cube.add_cell_method( + CellMethod( + method="mean", + coords=["y", "time"], + intervals=["10m", "3min"], + comments=["vertical", "=duration"], + ) + ) + rep = cube_replines(cube) + # Note: not alphabetical -- provided order is significant + expected = [ + "name / (1) (-- : 1)", + " Cell methods:", + " stdev: area", + " mean: y (10m, vertical), time (3min, =duration)", + ] + self.assertEqual(rep, expected) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/representation/cube_printout/test_Table.py b/lib/iris/tests/unit/representation/cube_printout/test_Table.py new file mode 100644 index 0000000000..89734ab878 --- /dev/null +++ b/lib/iris/tests/unit/representation/cube_printout/test_Table.py @@ -0,0 +1,160 @@ +# 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 :class:`iris._representation.cube_printout.Table`.""" +import iris.tests as tests + +from iris._representation.cube_printout import Table + + +class TestTable(tests.IrisTest): + # Note: this is just barely an independent definition, not *strictly* part + # of CubePrinter, but effectively more-or-less so. + def setUp(self): + table = Table() + table.add_row(["one", "b", "three"], aligns=["left", "right", "left"]) + table.add_row(["a", "two", "c"], aligns=["right", "left", "right"]) + self.simple_table = table + + def test_empty(self): + table = Table() + self.assertIsNone(table.n_columns) + self.assertEqual(len(table.rows), 0) + self.assertIsNone(table.col_widths) + # Check other methods : should be ok but do nothing. + table.set_min_column_widths() # Ok but does nothing. + self.assertIsNone(table.col_widths) + self.assertEqual(table.formatted_as_strings(), []) + self.assertEqual(str(table), "") + + def test_basic_content(self): + # Mirror the above 'empty' tests on a small basic table. + table = self.simple_table + self.assertEqual(table.n_columns, 3) + self.assertEqual(len(table.rows), 2) + self.assertIsNone(table.col_widths) + table.set_min_column_widths() # Ok but does nothing. + self.assertEqual(table.col_widths, [3, 3, 5]) + self.assertEqual( + table.formatted_as_strings(), ["one b three", " a two c"] + ) + self.assertEqual(str(table), "one b three\n a two c") + + def test_copy(self): + table = self.simple_table + # Add some detail information + table.rows[1].i_col_unlimited = 77 # Doesn't actually affect anything + table.col_widths = [10, 15, 12] + # Make the copy + table2 = table.copy() + self.assertIsNot(table2, table) + self.assertNotEqual(table2, table) # Note: equality is not implemented + # Check the parts match the original. + self.assertEqual(len(table2.rows), len(table.rows)) + for row2, row in zip(table2.rows, table.rows): + self.assertEqual(row2.cols, row.cols) + self.assertEqual(row2.aligns, row.aligns) + self.assertEqual(row2.i_col_unlimited, row.i_col_unlimited) + + def test_add_row(self): + table = Table() + self.assertEqual(table.n_columns, None) + # Add onw row. + table.add_row(["one", "two", "three"], aligns=["left", "left", "left"]) + self.assertEqual(len(table.rows), 1) + self.assertEqual(table.n_columns, 3) + self.assertIsNone(table.rows[0].i_col_unlimited) + # Second row ok. + table.add_row( + ["x", "y", "z"], + aligns=["right", "right", "right"], + i_col_unlimited=199, + ) + self.assertEqual(len(table.rows), 2) + self.assertEqual(table.rows[-1].i_col_unlimited, 199) + + # Fails with bad number of columns + regex = "columns.*!=.*existing" + with self.assertRaisesRegex(ValueError, regex): + table.add_row(["1", "2"], ["left", "right"]) + + # Fails with bad number of aligns + regex = "aligns.*!=.*col" + with self.assertRaisesRegex(ValueError, regex): + table.add_row(["1", "2", "3"], ["left", "left", "left", "left"]) + + def test_formatted_as_strings(self): + # Test simple self-print is same as + table = Table() + aligns = ["left", "right", "left"] + table.add_row(["1", "266", "32"], aligns) + table.add_row(["123", "2", "3"], aligns) + + # Check that printing calculates default column widths, and result.. + self.assertEqual(table.col_widths, None) + result = table.formatted_as_strings() + self.assertEqual(result, ["1 266 32", "123 2 3"]) + self.assertEqual(table.col_widths, [3, 3, 2]) + + def test_fail_bad_alignments(self): + # Invalid 'aligns' content : only detected when printed + table = Table() + table.add_row(["1", "2", "3"], ["left", "right", "BAD"]) + regex = 'Unknown alignment "BAD"' + with self.assertRaisesRegex(ValueError, regex): + str(table) + + def test_table_set_width(self): + # Check that changes do *not* affect pre-existing widths. + table = Table() + aligns = ["left", "right", "left"] + table.col_widths = [3, 3, 2] + table.add_row(["333", "333", "22"], aligns) + table.add_row(["a", "b", "c"], aligns) + table.add_row(["12345", "12345", "12345"], aligns) + result = table.formatted_as_strings() + self.assertEqual(table.col_widths, [3, 3, 2]) + self.assertEqual( + result, + [ + "333 333 22", + "a b c", + "12345 12345 12345", # These are exceeding the given widths. + ], + ) + + def test_unlimited_column(self): + table = Table() + aligns = ["left", "right", "left"] + table.add_row(["a", "beee", "c"], aligns) + table.add_row( + ["abcd", "any-longer-stuff", "this"], aligns, i_col_unlimited=1 + ) + table.add_row(["12", "x", "yy"], aligns) + result = table.formatted_as_strings() + self.assertEqual( + result, + [ + "a beee c", + "abcd any-longer-stuff this", + # NOTE: the widths-calc is ignoring cols 1-2, but + # entry#0 *is* extending the width of col#0 + "12 x yy", + ], + ) + + def test_str(self): + # Check that str returns the formatted_as_strings() output. + table = Table() + aligns = ["left", "left", "left"] + table.add_row(["one", "two", "three"], aligns=aligns) + table.add_row(["1", "2", "3"], aligns=aligns) + expected = "\n".join(table.formatted_as_strings()) + result = str(table) + self.assertEqual(result, expected) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/representation/cube_summary/__init__.py b/lib/iris/tests/unit/representation/cube_summary/__init__.py new file mode 100644 index 0000000000..c20a621ba2 --- /dev/null +++ b/lib/iris/tests/unit/representation/cube_summary/__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.cube_summary` module.""" diff --git a/lib/iris/tests/unit/representation/test_representation.py b/lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.py similarity index 93% rename from lib/iris/tests/unit/representation/test_representation.py rename to lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.py index 69d2a71a97..bb2b5cde3e 100644 --- a/lib/iris/tests/unit/representation/test_representation.py +++ b/lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.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 :class:`iris._representation.cube_summary.CubeSummary`.""" 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.cube_summary import CubeSummary + 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 = CubeSummary(self.cube) header_left = rep.header.nameunit header_right = rep.header.dimension_header.contents @@ -45,7 +46,7 @@ def test_header(self): def test_blank_cube(self): cube = Cube([1, 2]) - rep = iris._representation.CubeSummary(cube) + rep = CubeSummary(cube) self.assertEqual(rep.header.nameunit, "unknown / (unknown)") self.assertEqual(rep.header.dimension_header.contents, ["-- : 2"]) @@ -81,7 +82,7 @@ def test_blank_cube(self): self.assertTrue(scalar_section.is_empty()) def test_vector_coord(self): - rep = iris._representation.CubeSummary(self.cube) + rep = CubeSummary(self.cube) dim_section = rep.vector_sections["Dimension coordinates:"] self.assertEqual(len(dim_section.contents), 1) @@ -115,7 +116,7 @@ def test_scalar_coord(self): cube.add_aux_coord(scalar_coord_with_bounds) cube.add_aux_coord(scalar_coord_simple_text) cube.add_aux_coord(scalar_coord_awkward_text) - rep = iris._representation.CubeSummary(cube) + rep = CubeSummary(cube) scalar_section = rep.scalar_sections["Scalar coordinates:"] @@ -148,7 +149,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 = CubeSummary(cube) cm_section = rep.vector_sections["Cell measures:"] self.assertEqual(len(cm_section.contents), 1) @@ -161,7 +162,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 = CubeSummary(cube) av_section = rep.vector_sections["Ancillary variables:"] self.assertEqual(len(av_section.contents), 1) @@ -173,7 +174,7 @@ def test_ancillary_variable(self): def test_attributes(self): cube = self.cube cube.attributes = {"a": 1, "b": "two", "c": " this \n that\tand."} - rep = iris._representation.CubeSummary(cube) + rep = CubeSummary(cube) attribute_section = rep.scalar_sections["Attributes:"] attribute_contents = attribute_section.contents @@ -192,7 +193,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 = 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) @@ -201,7 +202,7 @@ def test_scalar_cube(self): cube = self.cube while cube.ndim > 0: cube = cube[0] - rep = iris._representation.CubeSummary(cube) + rep = 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, []) @@ -228,7 +229,7 @@ def test_coord_attributes(self): 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) + rep = 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. @@ -244,7 +245,7 @@ def test_array_attributes(self): 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) + rep = 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. ])") @@ -287,7 +288,7 @@ def test_attributes_subtle_differences(self): for co in (co3a, co3b): cube.add_aux_coord(co) - rep = iris._representation.CubeSummary(cube) + rep = 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])")