From 7d73cf20a75ff55230bf108bc8689f9edd24bb76 Mon Sep 17 00:00:00 2001 From: Bill Little Date: Tue, 9 Feb 2021 10:15:38 +0000 Subject: [PATCH 01/17] add nox session conda list (#3990) --- .cirrus.yml | 10 +- .../contributing_running_tests.rst | 8 + docs/src/whatsnew/latest.rst | 4 + noxfile.py | 148 +++++++----------- 4 files changed, 73 insertions(+), 97 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index 971bd3b81b..007bab403e 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -107,7 +107,7 @@ linux_minimal_task: tests_script: - echo "[Resources]" > ${SITE_CFG} - echo "doc_dir = ${CIRRUS_WORKING_DIR}/docs" >> ${SITE_CFG} - - nox --session tests + - nox --session tests -- --verbose # @@ -137,7 +137,7 @@ linux_task: - echo "[Resources]" > ${SITE_CFG} - echo "test_data_dir = ${IRIS_TEST_DATA_DIR}/test_data" >> ${SITE_CFG} - echo "doc_dir = ${CIRRUS_WORKING_DIR}/docs" >> ${SITE_CFG} - - nox --session tests + - nox --session tests -- --verbose # @@ -167,7 +167,7 @@ gallery_task: - echo "[Resources]" > ${SITE_CFG} - echo "test_data_dir = ${IRIS_TEST_DATA_DIR}/test_data" >> ${SITE_CFG} - echo "doc_dir = ${CIRRUS_WORKING_DIR}/docs" >> ${SITE_CFG} - - nox --session gallery + - nox --session gallery -- --verbose # @@ -201,7 +201,7 @@ doctest_task: - mkdir -p ${MPL_RC_DIR} - echo "backend : agg" > ${MPL_RC_FILE} - echo "image.cmap : viridis" >> ${MPL_RC_FILE} - - nox --session doctest + - nox --session doctest -- --verbose # @@ -224,4 +224,4 @@ link_task: - mkdir -p ${MPL_RC_DIR} - echo "backend : agg" > ${MPL_RC_FILE} - echo "image.cmap : viridis" >> ${MPL_RC_FILE} - - nox --session linkcheck + - nox --session linkcheck -- --verbose diff --git a/docs/src/developers_guide/contributing_running_tests.rst b/docs/src/developers_guide/contributing_running_tests.rst index 99ea4e831c..0fd9fa8486 100644 --- a/docs/src/developers_guide/contributing_running_tests.rst +++ b/docs/src/developers_guide/contributing_running_tests.rst @@ -175,6 +175,14 @@ For further `nox`_ command-line options:: nox --help +.. tip:: + For `nox`_ sessions that use the `conda`_ backend, you can use the ``-v`` or ``--verbose`` + flag to display the `nox`_ `conda`_ environment package details and environment info. + For example:: + + nox --session tests -- --verbose + + .. note:: `nox`_ will cache its testing environments in the `.nox` root ``iris`` project directory. diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst index fbb98cb1e3..ed11f60719 100644 --- a/docs/src/whatsnew/latest.rst +++ b/docs/src/whatsnew/latest.rst @@ -94,6 +94,10 @@ This document explains the changes made to Iris for this release #. `@jamesp`_ updated a test to the latest numpy version (:pull:`3977`) +#. `@bjlittle`_ rationalised the ``noxfile.py``, and added the ability for + each ``nox`` session to list its ``conda`` environment packages and + environment info. (:pull:`3990`) + .. comment Whatsnew author names (@github name) in alphabetical order. Note that, diff --git a/noxfile.py b/noxfile.py index fc6175bdf0..b6f9480290 100644 --- a/noxfile.py +++ b/noxfile.py @@ -93,6 +93,58 @@ def cache_cartopy(session): ) +def prepare_venv(session): + """ + Create and cache the nox session conda environment, and additionally + provide conda environment package details and info. + + Note that, iris is installed into the environment using pip. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + Notes + ----- + See + - https://github.com/theacodes/nox/issues/346 + - https://github.com/theacodes/nox/issues/260 + + """ + if not venv_cached(session): + # Determine the conda requirements yaml file. + fname = f"requirements/ci/py{session.python.replace('.', '')}.yml" + # Back-door approach to force nox to use "conda env update". + command = ( + "conda", + "env", + "update", + f"--prefix={session.virtualenv.location}", + f"--file={fname}", + "--prune", + ) + session._run(*command, silent=True, external="error") + cache_venv(session) + + cache_cartopy(session) + session.install("--no-deps", "--editable", ".") + + # Determine whether verbose diagnostics have been requested + # from the command line. + verbose = "-v" in session.posargs or "--verbose" in session.posargs + + if verbose: + session.run("conda", "info") + session.run("conda", "list", f"--prefix={session.virtualenv.location}") + session.run( + "conda", + "list", + f"--prefix={session.virtualenv.location}", + "--explicit", + ) + + @nox.session def flake8(session): """ @@ -141,30 +193,8 @@ def tests(session): session: object A `nox.sessions.Session` object. - Notes - ----- - See - - https://github.com/theacodes/nox/issues/346 - - https://github.com/theacodes/nox/issues/260 - """ - if not venv_cached(session): - # Determine the conda requirements yaml file. - fname = f"requirements/ci/py{session.python.replace('.', '')}.yml" - # Back-door approach to force nox to use "conda env update". - command = ( - "conda", - "env", - "update", - f"--prefix={session.virtualenv.location}", - f"--file={fname}", - "--prune", - ) - session._run(*command, silent=True, external="error") - cache_venv(session) - - cache_cartopy(session) - session.install("--no-deps", "--editable", ".") + prepare_venv(session) session.run( "python", "-m", @@ -184,30 +214,8 @@ def gallery(session): session: object A `nox.sessions.Session` object. - Notes - ----- - See - - https://github.com/theacodes/nox/issues/346 - - https://github.com/theacodes/nox/issues/260 - """ - if not venv_cached(session): - # Determine the conda requirements yaml file. - fname = f"requirements/ci/py{session.python.replace('.', '')}.yml" - # Back-door approach to force nox to use "conda env update". - command = ( - "conda", - "env", - "update", - f"--prefix={session.virtualenv.location}", - f"--file={fname}", - "--prune", - ) - session._run(*command, silent=True, external="error") - cache_venv(session) - - cache_cartopy(session) - session.install("--no-deps", "--editable", ".") + prepare_venv(session) session.run( "python", "-m", @@ -226,30 +234,8 @@ def doctest(session): session: object A `nox.sessions.Session` object. - Notes - ----- - See - - https://github.com/theacodes/nox/issues/346 - - https://github.com/theacodes/nox/issues/260 - """ - if not venv_cached(session): - # Determine the conda requirements yaml file. - fname = f"requirements/ci/py{session.python.replace('.', '')}.yml" - # Back-door approach to force nox to use "conda env update". - command = ( - "conda", - "env", - "update", - f"--prefix={session.virtualenv.location}", - f"--file={fname}", - "--prune", - ) - session._run(*command, silent=True, external="error") - cache_venv(session) - - cache_cartopy(session) - session.install("--no-deps", "--editable", ".") + prepare_venv(session) session.cd("docs") session.run( "make", @@ -274,30 +260,8 @@ def linkcheck(session): session: object A `nox.sessions.Session` object. - Notes - ----- - See - - https://github.com/theacodes/nox/issues/346 - - https://github.com/theacodes/nox/issues/260 - """ - if not venv_cached(session): - # Determine the conda requirements yaml file. - fname = f"requirements/ci/py{session.python.replace('.', '')}.yml" - # Back-door approach to force nox to use "conda env update". - command = ( - "conda", - "env", - "update", - f"--prefix={session.virtualenv.location}", - f"--file={fname}", - "--prune", - ) - session._run(*command, silent=True, external="error") - cache_venv(session) - - cache_cartopy(session) - session.install("--no-deps", "--editable", ".") + prepare_venv(session) session.cd("docs") session.run( "make", From c51dab213b92b9e7eb1a95e5f650c9fec0f5b9d4 Mon Sep 17 00:00:00 2001 From: tkknight <2108488+tkknight@users.noreply.github.com> Date: Tue, 9 Feb 2021 12:14:40 +0000 Subject: [PATCH 02/17] Added text to state the Python version used to build the docs. (#3989) * Added text to state the Python version used to build the docs. * Added footer template that includes the Python version used to build. * added new line * Review actions * added whatsnew --- docs/src/_templates/footer.html | 5 +++++ docs/src/conf.py | 14 +++++++++----- .../contributing_documentation.rst | 3 +++ docs/src/installing.rst | 4 +++- docs/src/whatsnew/latest.rst | 4 ++++ 5 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 docs/src/_templates/footer.html diff --git a/docs/src/_templates/footer.html b/docs/src/_templates/footer.html new file mode 100644 index 0000000000..1d5fb08b78 --- /dev/null +++ b/docs/src/_templates/footer.html @@ -0,0 +1,5 @@ +{% extends "!footer.html" %} +{% block extrafooter %} + Built using Python {{ python_version }}. + {{ super() }} +{% endblock %} diff --git a/docs/src/conf.py b/docs/src/conf.py index 30e6150b39..843af17944 100644 --- a/docs/src/conf.py +++ b/docs/src/conf.py @@ -69,8 +69,8 @@ def autolog(message): # define the copyright information for latex builds. Note, for html builds, # the copyright exists directly inside "_templates/layout.html" -upper_copy_year = datetime.datetime.now().year -copyright = "Iris Contributors" +copyright_years = f"2010 - {datetime.datetime.now().year}" +copyright = f"{copyright_years}, Iris Contributors" author = "Iris Developers" # The version info for the project you're documenting, acts as replacement for @@ -95,9 +95,12 @@ def autolog(message): # Create a variable that can be inserted in the rst "|copyright_years|". # You can add more variables here if needed. + +build_python_version = ".".join([str(i) for i in sys.version_info[:3]]) + rst_epilog = f""" -.. |copyright_years| replace:: 2010 - {upper_copy_year} -.. |python_version| replace:: {'.'.join([str(i) for i in sys.version_info[:3]])} +.. |copyright_years| replace:: {copyright_years} +.. |python_version| replace:: {build_python_version} .. |iris_version| replace:: v{version} .. |build_date| replace:: ({datetime.datetime.now().strftime('%d %b %Y')}) """ @@ -225,7 +228,8 @@ def autolog(message): } html_context = { - "copyright_years": "2010 - {}".format(upper_copy_year), + "copyright_years": copyright_years, + "python_version": build_python_version, # menu_links and menu_links_name are used in _templates/layout.html # to include some nice icons. See http://fontawesome.io for a list of # icons (used in the sphinx_rtd_theme) diff --git a/docs/src/developers_guide/contributing_documentation.rst b/docs/src/developers_guide/contributing_documentation.rst index 75e9dfe29c..167e8937b9 100644 --- a/docs/src/developers_guide/contributing_documentation.rst +++ b/docs/src/developers_guide/contributing_documentation.rst @@ -24,6 +24,9 @@ The documentation uses specific packages that need to be present. Please see Building ~~~~~~~~ +This documentation was built using the latest Python version that Iris +supports. For more information see :ref:`installing_iris`. + The build can be run from the documentation directory ``docs/src``. The build output for the html is found in the ``_build/html`` sub directory. diff --git a/docs/src/installing.rst b/docs/src/installing.rst index 8b3ae8d3e7..31fc497b85 100644 --- a/docs/src/installing.rst +++ b/docs/src/installing.rst @@ -17,7 +17,9 @@ any WSL_ distributions. .. _WSL: https://docs.microsoft.com/en-us/windows/wsl/install-win10 .. note:: Iris currently supports and is tested against **Python 3.6** and - **Python 3.7**. + **Python 3.7**. + +.. note:: This documentation was built using Python |python_version|. .. _installing_using_conda: diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst index ed11f60719..1efa08874a 100644 --- a/docs/src/whatsnew/latest.rst +++ b/docs/src/whatsnew/latest.rst @@ -83,6 +83,10 @@ This document explains the changes made to Iris for this release #. `@bjlittle`_ added automated Iris version discovery for the ``latest.rst`` in the ``whatsnew`` documentation. (:pull:`3981`) +#. `@tkknight`_ stated the Python version used to build the documentation + on :ref:`installing_iris` and to the footer of all pages. Also added the + copyright years to the footer. (:pull:`3989`) + 💼 Internal =========== From 8fb33bb8a9975f311795ae6c1f4301175b10307d Mon Sep 17 00:00:00 2001 From: Bill Little Date: Tue, 9 Feb 2021 13:36:18 +0000 Subject: [PATCH 03/17] Iris py38 (#3976) * support for py38 * update CI and noxfile * enforce alphabetical xml element attribute order * full tests for py38 + fix docs-tests * add whatsnew entry * update doc-strings + review actions * Alternate xml handling routine (#29) * all xml tests pass for nox tests-3.8 * restored docstrings * move sort_xml_attrs * make sort_xml_attrs a classmethod * update sort_xml_attr doc-string Co-authored-by: Bill Little * add jamesp to whatsnew + minor tweak Co-authored-by: James Penn --- .cirrus.yml | 12 +-- docs/src/common_links.inc | 2 + .../contributing_running_tests.rst | 2 - docs/src/further_topics/metadata.rst | 7 +- docs/src/installing.rst | 4 +- docs/src/whatsnew/latest.rst | 8 +- lib/iris/coords.py | 77 +++++++++++++++++-- lib/iris/cube.py | 56 ++++++++++++++ lib/iris/tests/__init__.py | 4 + noxfile.py | 2 +- requirements/ci/iris.yml | 2 +- requirements/ci/py38.yml | 51 ++++++++++++ 12 files changed, 203 insertions(+), 24 deletions(-) create mode 100644 requirements/ci/py38.yml diff --git a/.cirrus.yml b/.cirrus.yml index 007bab403e..da425a5691 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -98,6 +98,8 @@ linux_minimal_task: PY_VER: 3.6 env: PY_VER: 3.7 + env: + PY_VER: 3.8 name: "${CIRRUS_OS}: py${PY_VER} tests (minimal)" container: image: gcc:latest @@ -119,6 +121,8 @@ linux_task: PY_VER: 3.6 env: PY_VER: 3.7 + env: + PY_VER: 3.8 name: "${CIRRUS_OS}: py${PY_VER} tests (full)" container: image: gcc:latest @@ -146,9 +150,7 @@ linux_task: gallery_task: matrix: env: - PY_VER: 3.6 - env: - PY_VER: 3.7 + PY_VER: 3.8 name: "${CIRRUS_OS}: py${PY_VER} doc tests (gallery)" container: image: gcc:latest @@ -176,7 +178,7 @@ gallery_task: doctest_task: matrix: env: - PY_VER: 3.7 + PY_VER: 3.8 name: "${CIRRUS_OS}: py${PY_VER} doc tests" container: image: gcc:latest @@ -210,7 +212,7 @@ doctest_task: link_task: matrix: env: - PY_VER: 3.7 + PY_VER: 3.8 name: "${CIRRUS_OS}: py${PY_VER} doc link check" container: image: gcc:latest diff --git a/docs/src/common_links.inc b/docs/src/common_links.inc index 9f6a57f529..157444d65d 100644 --- a/docs/src/common_links.inc +++ b/docs/src/common_links.inc @@ -1,6 +1,7 @@ .. comment Common resources in alphabetical order: +.. _black: https://black.readthedocs.io/en/stable/ .. _.cirrus.yml: https://github.com/SciTools/iris/blob/master/.cirrus.yml .. _.flake8.yml: https://github.com/SciTools/iris/blob/master/.flake8 .. _cirrus-ci: https://cirrus-ci.com/github/SciTools/iris @@ -19,6 +20,7 @@ .. _legacy documentation: https://scitools.org.uk/iris/docs/v2.4.0/ .. _matplotlib: https://matplotlib.org/ .. _napolean: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/sphinxcontrib.napoleon.html +.. _nox: https://nox.thea.codes/en/stable/ .. _New Issue: https://github.com/scitools/iris/issues/new/choose .. _pull request: https://github.com/SciTools/iris/pulls .. _pull requests: https://github.com/SciTools/iris/pulls diff --git a/docs/src/developers_guide/contributing_running_tests.rst b/docs/src/developers_guide/contributing_running_tests.rst index 0fd9fa8486..9bc2d797bd 100644 --- a/docs/src/developers_guide/contributing_running_tests.rst +++ b/docs/src/developers_guide/contributing_running_tests.rst @@ -186,8 +186,6 @@ For further `nox`_ command-line options:: .. note:: `nox`_ will cache its testing environments in the `.nox` root ``iris`` project directory. -.. _black: https://black.readthedocs.io/en/stable/ -.. _nox: https://nox.thea.codes/en/latest/ .. _setuptools: https://setuptools.readthedocs.io/en/latest/ .. _tox: https://tox.readthedocs.io/en/latest/ .. _virtualenv: https://virtualenv.pypa.io/en/latest/ diff --git a/docs/src/further_topics/metadata.rst b/docs/src/further_topics/metadata.rst index e6d6ebc57a..ab6a6450b4 100644 --- a/docs/src/further_topics/metadata.rst +++ b/docs/src/further_topics/metadata.rst @@ -258,12 +258,12 @@ create a **new** instance directly from the metadata class itself, >>> DimCoordMetadata._make(values) DimCoordMetadata(standard_name=1, long_name=2, var_name=3, units=4, attributes=5, coord_system=6, climatological=7, circular=8) -It is also possible to easily convert ``metadata`` to an `OrderedDict`_ +It is also possible to easily convert ``metadata`` to an `dict`_ using the `namedtuple._asdict`_ method. This can be particularly handy when a standard Python built-in container is required to represent your ``metadata``, >>> metadata._asdict() - OrderedDict([('standard_name', 'longitude'), ('long_name', None), ('var_name', 'longitude'), ('units', Unit('degrees')), ('attributes', {'grinning face': '🙃'}), ('coord_system', GeogCS(6371229.0)), ('climatological', False), ('circular', False)]) + {'standard_name': 'longitude', 'long_name': None, 'var_name': 'longitude', 'units': Unit('degrees'), 'attributes': {'grinning face': '🙃'}, 'coord_system': GeogCS(6371229.0), 'climatological': False, 'circular': False} Using the `namedtuple._replace`_ method allows you to create a new metadata class instance, but replacing specified members with **new** associated values, @@ -943,7 +943,7 @@ such as a `dict`_, >>> mapping = latitude.metadata._asdict() >>> mapping - OrderedDict([('standard_name', 'latitude'), ('long_name', None), ('var_name', 'latitude'), ('units', Unit('degrees')), ('attributes', {}), ('coord_system', GeogCS(6371229.0)), ('climatological', False), ('circular', False)]) + {'standard_name': 'latitude', 'long_name': None, 'var_name': 'latitude', 'units': Unit('degrees'), 'attributes': {}, 'coord_system': GeogCS(6371229.0), 'climatological': False, 'circular': False} >>> longitude.metadata = mapping >>> longitude.metadata DimCoordMetadata(standard_name='latitude', long_name=None, var_name='latitude', units=Unit('degrees'), attributes={}, coord_system=GeogCS(6371229.0), climatological=False, circular=False) @@ -1000,7 +1000,6 @@ values. All other metadata members will be left unaltered. .. _NetCDF: https://www.unidata.ucar.edu/software/netcdf/ .. _NetCDF CF Metadata Conventions: https://cfconventions.org/ .. _NumPy: https://github.com/numpy/numpy -.. _OrderedDict: https://docs.python.org/3/library/collections.html#collections.OrderedDict .. _Parametric Vertical Coordinate: https://cfconventions.org/Data/cf-conventions/cf-conventions-1.8/cf-conventions.html#parametric-vertical-coordinate .. _rich comparison: https://www.python.org/dev/peps/pep-0207/ .. _SciTools/iris: https://github.com/SciTools/iris diff --git a/docs/src/installing.rst b/docs/src/installing.rst index 31fc497b85..8deb7043c5 100644 --- a/docs/src/installing.rst +++ b/docs/src/installing.rst @@ -16,8 +16,8 @@ any WSL_ distributions. .. _WSL: https://docs.microsoft.com/en-us/windows/wsl/install-win10 -.. note:: Iris currently supports and is tested against **Python 3.6** and - **Python 3.7**. +.. note:: Iris is currently supported and tested against Python ``3.6``, + ``3.7``, and ``3.8``. .. note:: This documentation was built using Python |python_version|. diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst index 1efa08874a..c02b61341b 100644 --- a/docs/src/whatsnew/latest.rst +++ b/docs/src/whatsnew/latest.rst @@ -12,7 +12,6 @@ This document explains the changes made to Iris for this release :title: text-primary text-center font-weight-bold :body: bg-light :animate: fade-in - :open: The highlights for this major/minor release of Iris include: @@ -96,7 +95,10 @@ This document explains the changes made to Iris for this release #. `@tkknight`_ moved the ``docs/iris`` directory to be in the parent directory ``docs``. (:pull:`3975`) -#. `@jamesp`_ updated a test to the latest numpy version (:pull:`3977`) +#. `@jamesp`_ updated a test for `numpy`_ ``1.20.0``. (:pull:`3977`) + +#. `@bjlittle`_ and `@jamesp`_ extended the `cirrus-ci`_ testing and `nox`_ + testing automation to support `Python 3.8`_. (:pull:`3976`) #. `@bjlittle`_ rationalised the ``noxfile.py``, and added the ability for each ``nox`` session to list its ``conda`` environment packages and @@ -117,3 +119,5 @@ This document explains the changes made to Iris for this release .. _abstract base class: https://docs.python.org/3/library/abc.html .. _GitHub: https://github.com/SciTools/iris/issues/new/choose .. _Met Office: https://www.metoffice.gov.uk/ +.. _numpy: https://numpy.org/doc/stable/release/1.20.0-notes.html +.. _Python 3.8: https://www.python.org/downloads/release/python-380/ diff --git a/lib/iris/coords.py b/lib/iris/coords.py index cfeb24cdcb..6129b35150 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -578,7 +578,21 @@ def shape(self): return self._values_dm.shape def xml_element(self, doc): - """Return a DOM element describing this metadata.""" + """ + Create the :class:`xml.dom.minidom.Element` that describes this + :class:`_DimensionalMetadata`. + + Args: + + * doc: + The parent :class:`xml.dom.minidom.Document`. + + Returns: + The :class:`xml.dom.minidom.Element` that will describe this + :class:`_DimensionalMetadata`, and the dictionary of attributes + that require to be added to this element. + + """ # Create the XML element as the camelCaseEquivalent of the # class name. element_name = type(self).__name__ @@ -881,6 +895,20 @@ def cube_dims(self, cube): return cube.cell_measure_dims(self) def xml_element(self, doc): + """ + Create the :class:`xml.dom.minidom.Element` that describes this + :class:`CellMeasure`. + + Args: + + * doc: + The parent :class:`xml.dom.minidom.Document`. + + Returns: + The :class:`xml.dom.minidom.Element` that describes this + :class:`CellMeasure`. + + """ # Create the XML element as the camelCaseEquivalent of the # class name element = super().xml_element(doc=doc) @@ -2228,14 +2256,26 @@ def nearest_neighbour_index(self, point): return result_index def xml_element(self, doc): - """Return a DOM element describing this Coord.""" + """ + Create the :class:`xml.dom.minidom.Element` that describes this + :class:`Coord`. + + Args: + + * doc: + The parent :class:`xml.dom.minidom.Document`. + + Returns: + The :class:`xml.dom.minidom.Element` that will describe this + :class:`DimCoord`, and the dictionary of attributes that require + to be added to this element. + + """ # Create the XML element as the camelCaseEquivalent of the # class name element = super().xml_element(doc=doc) - element.setAttribute("points", self._xml_array_repr(self.points)) - - # Add bounds handling + # Add bounds, points are handled by the parent class. if self.has_bounds(): element.setAttribute("bounds", self._xml_array_repr(self.bounds)) @@ -2614,7 +2654,20 @@ def is_monotonic(self): return True def xml_element(self, doc): - """Return DOM element describing this :class:`iris.coords.DimCoord`.""" + """ + Create the :class:`xml.dom.minidom.Element` that describes this + :class:`DimCoord`. + + Args: + + * doc: + The parent :class:`xml.dom.minidom.Document`. + + Returns: + The :class:`xml.dom.minidom.Element` that describes this + :class:`DimCoord`. + + """ element = super().xml_element(doc) if self.circular: element.setAttribute("circular", str(self.circular)) @@ -2794,7 +2847,17 @@ def __add__(self, other): def xml_element(self, doc): """ - Return a dom element describing itself + Create the :class:`xml.dom.minidom.Element` that describes this + :class:`CellMethod`. + + Args: + + * doc: + The parent :class:`xml.dom.minidom.Document`. + + Returns: + The :class:`xml.dom.minidom.Element` that describes this + :class:`CellMethod`. """ cellMethod_xml_element = doc.createElement("cellMethod") diff --git a/lib/iris/cube.py b/lib/iris/cube.py index 7c7d6c58e9..5578507d28 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -225,6 +225,7 @@ def __getslice__(self, start, stop): def xml(self, checksum=False, order=True, byteorder=True): """Return a string of the XML that this list of cubes represents.""" + doc = Document() cubes_xml_element = doc.createElement("cubes") cubes_xml_element.setAttribute("xmlns", XML_NAMESPACE_URI) @@ -239,6 +240,7 @@ def xml(self, checksum=False, order=True, byteorder=True): doc.appendChild(cubes_xml_element) # return our newly created XML string + doc = Cube._sort_xml_attrs(doc) return doc.toprettyxml(indent=" ") def extract(self, constraints): @@ -755,6 +757,59 @@ class Cube(CFVariableMixin): #: is similar to Fortran or Matlab, but different than numpy. __orthogonal_indexing__ = True + @classmethod + def _sort_xml_attrs(cls, doc): + """ + Takes an xml document and returns a copy with all element + attributes sorted in alphabetical order. + + This is a private utility method required by iris to maintain + legacy xml behaviour beyond python 3.7. + + Args: + + * doc: + The :class:`xml.dom.minidom.Document`. + + Returns: + The :class:`xml.dom.minidom.Document` with sorted element + attributes. + + """ + from xml.dom.minidom import Document + + def _walk_nodes(node): + """Note: _walk_nodes is called recursively on child elements.""" + + # we don't want to copy the children here, so take a shallow copy + new_node = node.cloneNode(deep=False) + + # Versions of python <3.8 order attributes in alphabetical order. + # Python >=3.8 order attributes in insert order. For consistent behaviour + # across both, we'll go with alphabetical order always. + # Remove all the attribute nodes, then add back in alphabetical order. + attrs = [ + new_node.getAttributeNode(attr_name).cloneNode(deep=True) + for attr_name in sorted(node.attributes.keys()) + ] + for attr in attrs: + new_node.removeAttributeNode(attr) + for attr in attrs: + new_node.setAttributeNode(attr) + + if node.childNodes: + children = [_walk_nodes(x) for x in node.childNodes] + for c in children: + new_node.appendChild(c) + + return new_node + + nodes = _walk_nodes(doc.documentElement) + new_doc = Document() + new_doc.appendChild(nodes) + + return new_doc + def __init__( self, data, @@ -3403,6 +3458,7 @@ def xml(self, checksum=False, order=True, byteorder=True): doc.appendChild(cube_xml_element) # Print our newly created XML + doc = self._sort_xml_attrs(doc) return doc.toprettyxml(indent=" ") def _xml_element(self, doc, checksum=False, order=True, byteorder=True): diff --git a/lib/iris/tests/__init__.py b/lib/iris/tests/__init__.py index ac0d313d76..4a85e5cdb2 100644 --- a/lib/iris/tests/__init__.py +++ b/lib/iris/tests/__init__.py @@ -573,6 +573,10 @@ def assertXMLElement(self, obj, reference_filename): """ doc = xml.dom.minidom.Document() doc.appendChild(obj.xml_element(doc)) + # sort the attributes on xml elements before testing against known good state. + # this is to be compatible with stored test output where xml attrs are stored in alphabetical order, + # (which was default behaviour in python <3.8, but changed to insert order in >3.8) + doc = iris.cube.Cube._sort_xml_attrs(doc) pretty_xml = doc.toprettyxml(indent=" ") reference_path = self.get_result_path(reference_filename) self._check_same( diff --git a/noxfile.py b/noxfile.py index b6f9480290..028da099dc 100644 --- a/noxfile.py +++ b/noxfile.py @@ -19,7 +19,7 @@ PACKAGE = str("lib" / Path("iris")) #: Cirrus-CI environment variable hook. -PY_VER = os.environ.get("PY_VER", ["3.6", "3.7"]) +PY_VER = os.environ.get("PY_VER", ["3.6", "3.7", "3.8"]) #: Default cartopy cache directory. CARTOPY_CACHE_DIR = os.environ.get("HOME") / Path(".local/share/cartopy") diff --git a/requirements/ci/iris.yml b/requirements/ci/iris.yml index e9adb956db..a76932b56e 120000 --- a/requirements/ci/iris.yml +++ b/requirements/ci/iris.yml @@ -1 +1 @@ -py37.yml \ No newline at end of file +py38.yml \ No newline at end of file diff --git a/requirements/ci/py38.yml b/requirements/ci/py38.yml new file mode 100644 index 0000000000..da29d30d71 --- /dev/null +++ b/requirements/ci/py38.yml @@ -0,0 +1,51 @@ +name: iris-dev + +channels: + - conda-forge + +dependencies: + - python=3.8 + +# Setup dependencies. + - setuptools>=40.8.0 + - pyke + +# Core dependencies. + - cartopy>=0.18 + - cf-units>=2 + - cftime<1.3.0 + - dask>=2 + - matplotlib + - netcdf4 + - numpy>=1.14 + - python-xxhash + - scipy + +# Optional dependencies. + - esmpy>=7.0 + - graphviz + - iris-sample-data + - mo_pack + - nc-time-axis + - pandas + - python-stratify + - pyugrid + +# Test dependencies. + - asv + - black=20.8b1 + - filelock + - flake8 + - imagehash>=4.0 + - nose + - pillow<7 + - pre-commit + - requests + +# Documentation dependencies. + - sphinx + - sphinxcontrib-napoleon + - sphinx-copybutton + - sphinx-gallery + - sphinx-panels + - sphinx_rtd_theme From 1549bae7a2e0b726745f28161d99f5dcb28a0ffd Mon Sep 17 00:00:00 2001 From: Bill Little Date: Tue, 9 Feb 2021 13:36:55 +0000 Subject: [PATCH 04/17] normalise version to implicit development release number (#3991) --- lib/iris/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/iris/__init__.py b/lib/iris/__init__.py index a78d0a7682..e31c7b58d7 100644 --- a/lib/iris/__init__.py +++ b/lib/iris/__init__.py @@ -106,7 +106,7 @@ def callback(cube, field, filename): # Iris revision. -__version__ = "3.1.0dev0" +__version__ = "3.1.dev0" # Restrict the names imported when using "from iris import *" __all__ = [ From 93171333a4fbc1a5484b7096382178e286fbc62e Mon Sep 17 00:00:00 2001 From: Ruth Comer Date: Wed, 10 Feb 2021 09:25:45 +0000 Subject: [PATCH 05/17] Gallery: update COP maps example (#3934) * update cop maps example * comment tweaks * minor comment tweak + whatsnew * reinstate whatsnew addition * remove duplicate whatsnew --- .../gallery_code/meteorology/plot_COP_maps.py | 134 ++++++++---------- docs/src/whatsnew/latest.rst | 4 +- 2 files changed, 61 insertions(+), 77 deletions(-) diff --git a/docs/gallery_code/meteorology/plot_COP_maps.py b/docs/gallery_code/meteorology/plot_COP_maps.py index 5555a0b85c..5e158346a9 100644 --- a/docs/gallery_code/meteorology/plot_COP_maps.py +++ b/docs/gallery_code/meteorology/plot_COP_maps.py @@ -38,34 +38,32 @@ def cop_metadata_callback(cube, field, filename): filename. """ - # Extract the experiment name (such as a1b or e1) from the filename (in - # this case it is just the parent folder's name) - containing_folder = os.path.dirname(filename) - experiment_label = os.path.basename(containing_folder) + # Extract the experiment name (such as A1B or E1) from the filename (in + # this case it is just the start of the file name, before the first "."). + fname = os.path.basename(filename) # filename without path. + experiment_label = fname.split(".")[0] - # Create a coordinate with the experiment label in it + # Create a coordinate with the experiment label in it... exp_coord = coords.AuxCoord( experiment_label, long_name="Experiment", units="no_unit" ) - # and add it to the cube + # ...and add it to the cube. cube.add_aux_coord(exp_coord) def main(): - # Load e1 and a1 using the callback to update the metadata - e1 = iris.load_cube( - iris.sample_data_path("E1.2098.pp"), callback=cop_metadata_callback - ) - a1b = iris.load_cube( - iris.sample_data_path("A1B.2098.pp"), callback=cop_metadata_callback - ) + # Load E1 and A1B scenarios using the callback to update the metadata. + scenario_files = [ + iris.sample_data_path(fname) for fname in ["E1.2098.pp", "A1B.2098.pp"] + ] + scenarios = iris.load(scenario_files, callback=cop_metadata_callback) - # Load the global average data and add an 'Experiment' coord it - global_avg = iris.load_cube(iris.sample_data_path("pre-industrial.pp")) + # Load the preindustrial reference data. + preindustrial = iris.load_cube(iris.sample_data_path("pre-industrial.pp")) # Define evenly spaced contour levels: -2.5, -1.5, ... 15.5, 16.5 with the - # specific colours + # specific colours. levels = np.arange(20) - 2.5 red = ( np.array( @@ -147,81 +145,67 @@ def main(): ) # Put those colours into an array which can be passed to contourf as the - # specific colours for each level - colors = np.array([red, green, blue]).T + # specific colours for each level. + colors = np.stack([red, green, blue], axis=1) - # Subtract the global + # Make a wider than normal figure to house two maps side-by-side. + fig, ax_array = plt.subplots(1, 2, figsize=(12, 5)) - # Iterate over each latitude longitude slice for both e1 and a1b scenarios - # simultaneously - for e1_slice, a1b_slice in zip( - e1.slices(["latitude", "longitude"]), - a1b.slices(["latitude", "longitude"]), + # Loop over our scenarios to make a plot for each. + for ax, experiment, label in zip( + ax_array, ["E1", "A1B"], ["E1", "A1B-Image"] ): - - time_coord = a1b_slice.coord("time") - - # Calculate the difference from the mean - delta_e1 = e1_slice - global_avg - delta_a1b = a1b_slice - global_avg - - # Make a wider than normal figure to house two maps side-by-side - fig = plt.figure(figsize=(12, 5)) - - # Get the time datetime from the coordinate - time = time_coord.units.num2date(time_coord.points[0]) - # Set a title for the entire figure, giving the time in a nice format - # of "MonthName Year". Also, set the y value for the title so that it - # is not tight to the top of the plot. - fig.suptitle( - "Annual Temperature Predictions for " + time.strftime("%Y"), - y=0.9, - fontsize=18, + exp_cube = scenarios.extract_cube( + iris.Constraint(Experiment=experiment) ) + time_coord = exp_cube.coord("time") - # Add the first subplot showing the E1 scenario - plt.subplot(121) - plt.title("HadGEM2 E1 Scenario", fontsize=10) - iplt.contourf(delta_e1, levels, colors=colors, extend="both") - plt.gca().coastlines() - # get the current axes' subplot for use later on - plt1_ax = plt.gca() + # Calculate the difference from the preindustial control run. + exp_anom_cube = exp_cube - preindustrial - # Add the second subplot showing the A1B scenario - plt.subplot(122) - plt.title("HadGEM2 A1B-Image Scenario", fontsize=10) + # Plot this anomaly. + plt.sca(ax) + ax.set_title(f"HadGEM2 {label} Scenario", fontsize=10) contour_result = iplt.contourf( - delta_a1b, levels, colors=colors, extend="both" + exp_anom_cube, levels, colors=colors, extend="both" ) plt.gca().coastlines() - # get the current axes' subplot for use later on - plt2_ax = plt.gca() - # Now add a colourbar who's leftmost point is the same as the leftmost - # point of the left hand plot and rightmost point is the rightmost - # point of the right hand plot + # Now add a colourbar who's leftmost point is the same as the leftmost + # point of the left hand plot and rightmost point is the rightmost + # point of the right hand plot. - # Get the positions of the 2nd plot and the left position of the 1st - # plot - left, bottom, width, height = plt2_ax.get_position().bounds - first_plot_left = plt1_ax.get_position().bounds[0] + # Get the positions of the 2nd plot and the left position of the 1st plot. + left, bottom, width, height = ax_array[1].get_position().bounds + first_plot_left = ax_array[0].get_position().bounds[0] - # the width of the colorbar should now be simple - width = left - first_plot_left + width + # The width of the colorbar should now be simple. + width = left - first_plot_left + width - # Add axes to the figure, to place the colour bar - colorbar_axes = fig.add_axes([first_plot_left, 0.18, width, 0.03]) + # Add axes to the figure, to place the colour bar. + colorbar_axes = fig.add_axes([first_plot_left, 0.18, width, 0.03]) - # Add the colour bar - cbar = plt.colorbar( - contour_result, colorbar_axes, orientation="horizontal" - ) + # Add the colour bar. + cbar = plt.colorbar( + contour_result, colorbar_axes, orientation="horizontal" + ) - # Label the colour bar and add ticks - cbar.set_label(e1_slice.units) - cbar.ax.tick_params(length=0) + # Label the colour bar and add ticks. + cbar.set_label(preindustrial.units) + cbar.ax.tick_params(length=0) + + # Get the time datetime from the coordinate. + time = time_coord.units.num2date(time_coord.points[0]) + # Set a title for the entire figure, using the year from the datetime + # object. Also, set the y value for the title so that it is not tight to + # the top of the plot. + fig.suptitle( + f"Annual Temperature Predictions for {time.year}", + y=0.9, + fontsize=18, + ) - iplt.show() + iplt.show() if __name__ == "__main__": diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst index c02b61341b..b290b7ab5a 100644 --- a/docs/src/whatsnew/latest.rst +++ b/docs/src/whatsnew/latest.rst @@ -69,8 +69,8 @@ This document explains the changes made to Iris for this release 📚 Documentation ================ -#. `@rcomer`_ updated the "Seasonal ensemble model plots" Gallery example. - (:pull:`3933`) +#. `@rcomer`_ updated the "Seasonal ensemble model plots" and "Global average + annual temperature maps" Gallery examples. (:pull:`3933` and :pull:`3934`) #. `@MHBalsmeier`_ described non-conda installation on Debian-based distros. (:pull:`3958`) From e378eb8caaf869e4ce07ed9ff12b3b3f64148e2c Mon Sep 17 00:00:00 2001 From: Ruth Comer Date: Wed, 10 Feb 2021 09:35:11 +0000 Subject: [PATCH 06/17] don't support mpl v1.2 (#3941) --- docs/gallery_code/meteorology/plot_deriving_phenomena.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/docs/gallery_code/meteorology/plot_deriving_phenomena.py b/docs/gallery_code/meteorology/plot_deriving_phenomena.py index 0bb1fa53a4..b600941f35 100644 --- a/docs/gallery_code/meteorology/plot_deriving_phenomena.py +++ b/docs/gallery_code/meteorology/plot_deriving_phenomena.py @@ -26,14 +26,7 @@ def limit_colorbar_ticks(contour_object): number of ticks on the colorbar to 4. """ - # Under Matplotlib v1.2.x the colorbar attribute of a contour object is - # a tuple containing the colorbar and an axes object, whereas under - # Matplotlib v1.3.x it is simply the colorbar. - try: - colorbar = contour_object.colorbar[0] - except (AttributeError, TypeError): - colorbar = contour_object.colorbar - + colorbar = contour_object.colorbar colorbar.locator = matplotlib.ticker.MaxNLocator(4) colorbar.update_ticks() From e3c190543733ee53d1b5af84ec590a4c876b6071 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Wed, 10 Feb 2021 11:38:22 +0000 Subject: [PATCH 07/17] Cubesummary tidy (#3988) * Extra tests; fix for array attributes. * Docstring for CubeSummary, and remove some unused parts. * Fix section name capitalisation, in line with existing cube summary. * Handle array differences; quote strings in extras and if 'awkward'-printing. * Ensure scalar string coord 'content' prints on one line. --- lib/iris/_representation.py | 72 +++++++-- .../representation/test_representation.py | 149 ++++++++++++++++-- 2 files changed, 191 insertions(+), 30 deletions(-) diff --git a/lib/iris/_representation.py b/lib/iris/_representation.py index 301f4a9a22..ee1e1a0d55 100644 --- a/lib/iris/_representation.py +++ b/lib/iris/_representation.py @@ -6,8 +6,10 @@ """ Provides objects describing cube summaries. """ +import re import iris.util +from iris.common.metadata import _hexdigest as quickhash class DimensionHeader: @@ -46,6 +48,35 @@ def __init__(self, cube, name_padding=35): self.dimension_header = DimensionHeader(cube) +def string_repr(text, quote_strings=False): + """Produce a one-line printable form of a text string.""" + if re.findall("[\n\t]", text) or quote_strings: + # Replace the string with its repr (including quotes). + text = repr(text) + return text + + +def array_repr(arr): + """Produce a single-line printable repr of an array.""" + # First take whatever numpy produces.. + text = repr(arr) + # ..then reduce any multiple spaces and newlines. + text = re.sub("[ \t\n]+", " ", text) + return text + + +def value_repr(value, quote_strings=False): + """ + Produce a single-line printable version of an attribute or scalar value. + """ + if hasattr(value, "dtype"): + value = array_repr(value) + elif isinstance(value, str): + value = string_repr(value, quote_strings=quote_strings) + value = str(value) + return value + + class CoordSummary: def _summary_coord_extra(self, cube, coord): # Returns the text needed to ensure this coordinate can be @@ -66,12 +97,21 @@ def _summary_coord_extra(self, cube, coord): vary.add(key) break value = similar_coord.attributes[key] - if attributes.setdefault(key, value) != value: + # Like "if attributes.setdefault(key, value) != value:" + # ..except setdefault fails if values are numpy arrays. + if key not in attributes: + attributes[key] = value + elif quickhash(attributes[key]) != quickhash(value): + # NOTE: fast and array-safe comparison, as used in + # :mod:`iris.common.metadata`. vary.add(key) break keys = sorted(vary & set(coord.attributes.keys())) bits = [ - "{}={!r}".format(key, coord.attributes[key]) for key in keys + "{}={}".format( + key, value_repr(coord.attributes[key], quote_strings=True) + ) + for key in keys ] if bits: extra = ", ".join(bits) @@ -105,13 +145,17 @@ def __init__(self, cube, coord): coord_cell = coord.cell(0) if isinstance(coord_cell.point, str): self.string_type = True + # 'lines' is value split on '\n', and _each one_ length-clipped. self.lines = [ iris.util.clip_string(str(item)) for item in coord_cell.point.split("\n") ] self.point = None self.bound = None - self.content = "\n".join(self.lines) + # 'content' contains a one-line printable version of the string, + content = string_repr(coord_cell.point) + content = iris.util.clip_string(content) + self.content = content else: self.string_type = False self.lines = None @@ -132,9 +176,6 @@ def __init__(self, cube, coord): class Section: - def _init_(self): - self.contents = [] - def is_empty(self): return self.contents == [] @@ -166,7 +207,8 @@ def __init__(self, title, attributes): self.values = [] self.contents = [] for name, value in sorted(attributes.items()): - value = iris.util.clip_string(str(value)) + value = value_repr(value) + value = iris.util.clip_string(value) self.names.append(name) self.values.append(value) content = "{}: {}".format(name, value) @@ -180,11 +222,13 @@ def __init__(self, title, cell_methods): class CubeSummary: + """ + This class provides a structure for output representations of an Iris cube. + TODO: use to produce the printout of :meth:`iris.cube.Cube.__str__`. + + """ + def __init__(self, cube, shorten=False, name_padding=35): - self.section_indent = 5 - self.item_indent = 10 - self.extra_indent = 13 - self.shorten = shorten self.header = FullHeader(cube, name_padding) # Cache the derived coords so we can rely on consistent @@ -249,9 +293,9 @@ def add_vector_section(title, contents, iscoord=True): add_vector_section("Dimension coordinates:", vector_dim_coords) add_vector_section("Auxiliary coordinates:", vector_aux_coords) add_vector_section("Derived coordinates:", vector_derived_coords) - add_vector_section("Cell Measures:", vector_cell_measures, False) + add_vector_section("Cell measures:", vector_cell_measures, False) add_vector_section( - "Ancillary Variables:", vector_ancillary_variables, False + "Ancillary variables:", vector_ancillary_variables, False ) self.scalar_sections = {} @@ -260,7 +304,7 @@ def add_scalar_section(section_class, title, *args): self.scalar_sections[title] = section_class(title, *args) add_scalar_section( - ScalarSection, "Scalar Coordinates:", cube, scalar_coords + ScalarSection, "Scalar coordinates:", cube, scalar_coords ) add_scalar_section( ScalarCellMeasureSection, diff --git a/lib/iris/tests/unit/representation/test_representation.py b/lib/iris/tests/unit/representation/test_representation.py index 212f454e70..69d2a71a97 100644 --- a/lib/iris/tests/unit/representation/test_representation.py +++ b/lib/iris/tests/unit/representation/test_representation.py @@ -54,8 +54,8 @@ def test_blank_cube(self): "Dimension coordinates:", "Auxiliary coordinates:", "Derived coordinates:", - "Cell Measures:", - "Ancillary Variables:", + "Cell measures:", + "Ancillary variables:", ] self.assertEqual( list(rep.vector_sections.keys()), expected_vector_sections @@ -66,7 +66,7 @@ def test_blank_cube(self): self.assertTrue(vector_section.is_empty()) expected_scalar_sections = [ - "Scalar Coordinates:", + "Scalar coordinates:", "Scalar cell measures:", "Attributes:", "Cell methods:", @@ -103,21 +103,28 @@ def test_scalar_coord(self): scalar_coord_with_bounds = AuxCoord( [10], long_name="foo", units="K", bounds=[(5, 15)] ) - scalar_coord_text = AuxCoord( - ["a\nb\nc"], long_name="foo", attributes={"key": "value"} + scalar_coord_simple_text = AuxCoord( + ["this and that"], + long_name="foo", + attributes={"key": 42, "key2": "value-str"}, + ) + scalar_coord_awkward_text = AuxCoord( + ["a is\nb\n and c"], long_name="foo_2" ) cube.add_aux_coord(scalar_coord_no_bounds) cube.add_aux_coord(scalar_coord_with_bounds) - cube.add_aux_coord(scalar_coord_text) + cube.add_aux_coord(scalar_coord_simple_text) + cube.add_aux_coord(scalar_coord_awkward_text) rep = iris._representation.CubeSummary(cube) - scalar_section = rep.scalar_sections["Scalar Coordinates:"] + scalar_section = rep.scalar_sections["Scalar coordinates:"] - self.assertEqual(len(scalar_section.contents), 3) + self.assertEqual(len(scalar_section.contents), 4) no_bounds_summary = scalar_section.contents[0] bounds_summary = scalar_section.contents[1] - text_summary = scalar_section.contents[2] + text_summary_simple = scalar_section.contents[2] + text_summary_awkward = scalar_section.contents[3] self.assertEqual(no_bounds_summary.name, "bar") self.assertEqual(no_bounds_summary.content, "10 K") @@ -127,9 +134,15 @@ def test_scalar_coord(self): self.assertEqual(bounds_summary.content, "10 K, bound=(5, 15) K") self.assertEqual(bounds_summary.extra, "") - self.assertEqual(text_summary.name, "foo") - self.assertEqual(text_summary.content, "a\nb\nc") - self.assertEqual(text_summary.extra, "key='value'") + self.assertEqual(text_summary_simple.name, "foo") + self.assertEqual(text_summary_simple.content, "this and that") + self.assertEqual(text_summary_simple.lines, ["this and that"]) + self.assertEqual(text_summary_simple.extra, "key=42, key2='value-str'") + + self.assertEqual(text_summary_awkward.name, "foo_2") + self.assertEqual(text_summary_awkward.content, r"'a is\nb\n and c'") + self.assertEqual(text_summary_awkward.lines, ["a is", "b", " and c"]) + self.assertEqual(text_summary_awkward.extra, "") def test_cell_measure(self): cube = self.cube @@ -137,7 +150,7 @@ def test_cell_measure(self): cube.add_cell_measure(cell_measure, 0) rep = iris._representation.CubeSummary(cube) - cm_section = rep.vector_sections["Cell Measures:"] + cm_section = rep.vector_sections["Cell measures:"] self.assertEqual(len(cm_section.contents), 1) cm_summary = cm_section.contents[0] @@ -150,7 +163,7 @@ def test_ancillary_variable(self): cube.add_ancillary_variable(cell_measure, 0) rep = iris._representation.CubeSummary(cube) - av_section = rep.vector_sections["Ancillary Variables:"] + av_section = rep.vector_sections["Ancillary variables:"] self.assertEqual(len(av_section.contents), 1) av_summary = av_section.contents[0] @@ -159,12 +172,14 @@ def test_ancillary_variable(self): def test_attributes(self): cube = self.cube - cube.attributes = {"a": 1, "b": "two"} + cube.attributes = {"a": 1, "b": "two", "c": " this \n that\tand."} rep = iris._representation.CubeSummary(cube) attribute_section = rep.scalar_sections["Attributes:"] attribute_contents = attribute_section.contents - expected_contents = ["a: 1", "b: two"] + expected_contents = ["a: 1", "b: two", "c: ' this \\n that\\tand.'"] + # Note: a string with \n or \t in it gets "repr-d". + # Other strings don't (though in coord 'extra' lines, they do.) self.assertEqual(attribute_contents, expected_contents) @@ -182,6 +197,108 @@ def test_cell_methods(self): expected_contents = ["mean: x, y", "mean: x"] self.assertEqual(cell_method_section.contents, expected_contents) + def test_scalar_cube(self): + cube = self.cube + while cube.ndim > 0: + cube = cube[0] + rep = iris._representation.CubeSummary(cube) + self.assertEqual(rep.header.nameunit, "air_temperature / (K)") + self.assertTrue(rep.header.dimension_header.scalar) + self.assertEqual(rep.header.dimension_header.dim_names, []) + self.assertEqual(rep.header.dimension_header.shape, []) + self.assertEqual(rep.header.dimension_header.contents, ["scalar cube"]) + self.assertEqual(len(rep.vector_sections), 5) + self.assertTrue( + all(sect.is_empty() for sect in rep.vector_sections.values()) + ) + self.assertEqual(len(rep.scalar_sections), 4) + self.assertEqual( + len(rep.scalar_sections["Scalar coordinates:"].contents), 1 + ) + self.assertTrue( + rep.scalar_sections["Scalar cell measures:"].is_empty() + ) + self.assertTrue(rep.scalar_sections["Attributes:"].is_empty()) + self.assertTrue(rep.scalar_sections["Cell methods:"].is_empty()) + + def test_coord_attributes(self): + cube = self.cube + co1 = cube.coord("latitude") + co1.attributes.update(dict(a=1, b=2)) + co2 = co1.copy() + co2.attributes.update(dict(a=7, z=77, text="ok", text2="multi\nline")) + cube.add_aux_coord(co2, cube.coord_dims(co1)) + rep = iris._representation.CubeSummary(cube) + co1_summ = rep.vector_sections["Dimension coordinates:"].contents[0] + co2_summ = rep.vector_sections["Auxiliary coordinates:"].contents[0] + # Notes: 'b' is same so does not appear; sorted order; quoted strings. + self.assertEqual(co1_summ.extra, "a=1") + self.assertEqual( + co2_summ.extra, "a=7, text='ok', text2='multi\\nline', z=77" + ) + + def test_array_attributes(self): + cube = self.cube + co1 = cube.coord("latitude") + co1.attributes.update(dict(a=1, array=np.array([1.2, 3]))) + co2 = co1.copy() + co2.attributes.update(dict(b=2, array=np.array([3.2, 1]))) + cube.add_aux_coord(co2, cube.coord_dims(co1)) + rep = iris._representation.CubeSummary(cube) + co1_summ = rep.vector_sections["Dimension coordinates:"].contents[0] + co2_summ = rep.vector_sections["Auxiliary coordinates:"].contents[0] + self.assertEqual(co1_summ.extra, "array=array([1.2, 3. ])") + self.assertEqual(co2_summ.extra, "array=array([3.2, 1. ]), b=2") + + def test_attributes_subtle_differences(self): + cube = Cube([0]) + + # Add a pair that differ only in having a list instead of an array. + co1a = DimCoord( + [0], + long_name="co1_list_or_array", + attributes=dict(x=1, arr1=np.array(2), arr2=np.array([1, 2])), + ) + co1b = co1a.copy() + co1b.attributes.update(dict(arr2=[1, 2])) + for co in (co1a, co1b): + cube.add_aux_coord(co) + + # Add a pair that differ only in an attribute array dtype. + co2a = AuxCoord( + [0], + long_name="co2_dtype", + attributes=dict(x=1, arr1=np.array(2), arr2=np.array([3, 4])), + ) + co2b = co2a.copy() + co2b.attributes.update(dict(arr2=np.array([3.0, 4.0]))) + assert co2b != co2a + for co in (co2a, co2b): + cube.add_aux_coord(co) + + # Add a pair that differ only in an attribute array shape. + co3a = DimCoord( + [0], + long_name="co3_shape", + attributes=dict(x=1, arr1=np.array([5, 6]), arr2=np.array([3, 4])), + ) + co3b = co3a.copy() + co3b.attributes.update(dict(arr1=np.array([[5], [6]]))) + for co in (co3a, co3b): + cube.add_aux_coord(co) + + rep = iris._representation.CubeSummary(cube) + co_summs = rep.scalar_sections["Scalar coordinates:"].contents + co1a_summ, co1b_summ = co_summs[0:2] + self.assertEqual(co1a_summ.extra, "arr2=array([1, 2])") + self.assertEqual(co1b_summ.extra, "arr2=[1, 2]") + co2a_summ, co2b_summ = co_summs[2:4] + self.assertEqual(co2a_summ.extra, "arr2=array([3, 4])") + self.assertEqual(co2b_summ.extra, "arr2=array([3., 4.])") + co3a_summ, co3b_summ = co_summs[4:6] + self.assertEqual(co3a_summ.extra, "arr1=array([5, 6])") + self.assertEqual(co3b_summ.extra, "arr1=array([[5], [6]])") + if __name__ == "__main__": tests.main() From 6cc41bc0672a49b8f5f009787410cbbef0f79eab Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Mon, 8 Feb 2021 11:44:07 +0000 Subject: [PATCH 08/17] Extra tests; fix for array attributes. --- lib/iris/_representation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/iris/_representation.py b/lib/iris/_representation.py index ee1e1a0d55..f5636bb9d0 100644 --- a/lib/iris/_representation.py +++ b/lib/iris/_representation.py @@ -8,6 +8,8 @@ """ import re +import numpy as np + import iris.util from iris.common.metadata import _hexdigest as quickhash From 7495f6a9f62988956728f462a265ff220ef87be3 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Mon, 25 Jan 2021 11:28:33 +0000 Subject: [PATCH 09/17] Rename _representation to _representation/cube_summary. --- lib/iris/_representation/__init__.py | 9 +++++++ .../cube_summary.py} | 0 ...representation.py => test_cube_summary.py} | 27 ++++++++++--------- 3 files changed, 23 insertions(+), 13 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} (94%) 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 94% rename from lib/iris/tests/unit/representation/test_representation.py rename to lib/iris/tests/unit/representation/test_cube_summary.py index 69d2a71a97..bdc066c955 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.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. ])") From 95542b8b089453f48df2c2fb684446ddc46d398a Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Mon, 8 Feb 2021 13:27:55 +0000 Subject: [PATCH 10/17] Cube printout code, rough testing only. --- lib/iris/_representation/cube_printout.py | 336 ++++++++++++++++++ .../unit/representation/test_cube_printout.py | 175 +++++++++ 2 files changed, 511 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..91da83e58d --- /dev/null +++ b/lib/iris/_representation/cube_printout.py @@ -0,0 +1,336 @@ +# 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 + + +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) + assert len(aligns) == n_cols + if self.n_columns is not None: + # For now, all rows must have same number of columns + assert n_cols == self.n_columns + 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) + result_lines.append(" ".join(col_texts)) + return result_lines + + def __str__(self): + return "\n".join(self.formatted_as_strings()) + + +class CubePrinter: + """ + An object created from a + :class:`iris._representation.cube_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_summary.CubeSummary`. + + """ + + def __init__(self, cube_summary): + # Create our internal table from the summary, to produce the printouts. + 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, max_width): + """Produce a multi-line summary string.""" + # Get a derived table with standard 'decorations' added. + table = self._decorated_table(self.table, name_padding=35) + result_lines = table.formatted_as_strings() + result = "\n".join(result_lines) + return result + + 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 + + if oneline: + result = self._oneline_string() + else: + result = self._multiline_summary(max_width) + + return result + + def __str__(self): + """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 new file mode 100644 index 0000000000..ddd1fb3763 --- /dev/null +++ b/lib/iris/tests/unit/representation/test_cube_printout.py @@ -0,0 +1,175 @@ +# 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, 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_4d() # this one has a derived coord + + # Optionally : add multiple extra dimensions to test the width controls + if n_extra_dims > 0: + + 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, + ) + 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, + ) + 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"), + latlon_dims, + ) + cube.add_aux_coord( + 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(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) + + # 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 _exercise_methods(self, cube): + summ = icr.CubeSummary(cube) + printer = CubePrinter(summ) + 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 :") + if unprintable: + print(" ( would fail, due to scalar-cube with scalar-ancils )") + else: + print(cube) + print("---full--- :") + print(printer.to_string()) + print("") + print("EXISTING oneline :") + print(repr(cube)) + print("---oneline--- :") + print(printer.to_string(oneline=True)) + print("") + print("original table form:") + tb = printer.table + 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) + + +if __name__ == "__main__": + tests.main() From 47c8de8fb51df0aa37d762bf83fc96ad4d796434 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Wed, 10 Feb 2021 09:27:30 +0000 Subject: [PATCH 11/17] Adding proper tests. --- lib/iris/_representation/cube_printout.py | 26 +- .../cube_printout/test_CubePrintout.py | 308 ++++++++++++++++++ .../test_CubeSummary.py} | 2 +- .../unit/representation/test_cube_printout.py | 175 ---------- 4 files changed, 328 insertions(+), 183 deletions(-) create mode 100644 lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py rename lib/iris/tests/unit/representation/{test_cube_summary.py => cube_summary/test_CubeSummary.py} (99%) delete 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 index 91da83e58d..6535789d9f 100644 --- a/lib/iris/_representation/cube_printout.py +++ b/lib/iris/_representation/cube_printout.py @@ -65,10 +65,20 @@ def add_row(self, cols, aligns, i_col_unlimited=None): """ n_cols = len(cols) - assert len(aligns) == n_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 - assert n_cols == self.n_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) @@ -107,7 +117,9 @@ def formatted_as_strings(self): ) raise ValueError(msg) col_texts.append(col_text) - result_lines.append(" ".join(col_texts)) + + row_line = " ".join(col_texts).rstrip() + result_lines.append(row_line) return result_lines def __str__(self): @@ -297,15 +309,15 @@ def _oneline_string(self): (oneline_result,) = table.formatted_as_strings() return oneline_result - def _multiline_summary(self, max_width): + 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=35) + 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, max_width=None): + def to_string(self, oneline=False, name_padding=35): """ Produce a printable summary. @@ -327,7 +339,7 @@ def to_string(self, oneline=False, max_width=None): if oneline: result = self._oneline_string() else: - result = self._multiline_summary(max_width) + result = self._multiline_summary(name_padding) return result 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..aa11bcd42a --- /dev/null +++ b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py @@ -0,0 +1,308 @@ +# 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.cube import Cube +from iris.coords import AuxCoord, DimCoord +from iris._representation.cube_summary import CubeSummary +import iris.tests.stock as istk +from iris.util import new_axis + +from iris._representation.cube_printout import CubePrinter, Table + + +class TestTable(tests.IrisTest): + 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 + with self.assertRaisesRegex(ValueError, "columns.*!=.*existing"): + table.add_row(["one"], ["left"]) + # Fails with bad number of aligns + with self.assertRaisesRegex(ValueError, "aligns.*!=.*col"): + table.add_row(["one", "two"], ["left"]) + + +def example_cube(n_extra_dims=0): + cube = istk.realistic_4d() # this one has a derived coord + + # Optionally : add multiple extra dimensions to test the width controls + if n_extra_dims > 0: + + 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, + ) + 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, + ) + 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"), + latlon_dims, + ) + cube.add_aux_coord( + 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(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) + + # 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 + + +def cube_repr(cube, **kwargs): + return CubePrinter(CubeSummary(cube)).to_string(**kwargs) + + +class TestCubePrintout__cubefeatures_summaries(tests.IrisTest): + def test_empty(self): + cube = Cube([0]) + repr = cube_repr(cube) + self.assertEqual(repr, "unknown / (unknown) (-- : 1)") + repr = cube_repr(cube, oneline=True) + self.assertEqual(repr, "unknown / (unknown) (-- : 1)") + + def test_scalar_cube(self): + cube = Cube(0) + repr = cube_repr(cube) + self.assertEqual( + repr, "unknown / (unknown) (scalar cube)" + ) + repr = cube_repr(cube, oneline=True) + self.assertEqual(repr, "unknown / (unknown) (scalar cube)") + + def test_name_padding(self): + cube = Cube([1, 2], long_name="cube_accel", units="ms-2") + repr = cube_repr(cube) + self.assertEqual(repr, "cube_accel / (ms-2) (-- : 2)") + repr = cube_repr(cube, name_padding=0) + self.assertEqual(repr, "cube_accel / (ms-2) (-- : 2)") + repr = cube_repr(cube, name_padding=25) + self.assertEqual(repr, "cube_accel / (ms-2) (-- : 2)") + + def test_long_coordname_columns(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) + repr = cube_repr(cube) + expected = ( + "short / (1) (-- : 1)\n" + " Auxiliary coordinates:\n" + " very_very_very_very_very_long_coord_name x" + ) + self.assertEqual(repr, expected) + repr = cube_repr(cube, oneline=True) + self.assertEqual(repr, "short / (1) (-- : 1)") + + def test_long_attribute_columns(self): + cube = Cube([0], long_name="short", units=1) + cube.attributes[ + "very_very_very_very_very_long_name" + ] = "longish string extends beyond dim columns" + repr = cube_repr(cube) + expected = ( + "short / (1) (-- : 1)\n" + " Attributes:\n" + " very_very_very_very_very_long_name " + "longish string extends beyond dim columns" + ) + self.assertEqual(repr, expected) + + +class TestCubePrintout__integration_examples(tests.IrisTest): + def _exercise_methods(self, cube): + summ = CubeSummary(cube) + printer = CubePrinter(summ) + 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 :") + if unprintable: + print(" ( would fail, due to scalar-cube with scalar-ancils )") + else: + print(cube) + print("---full--- :") + print(printer.to_string()) + print("") + print("EXISTING oneline :") + print(repr(cube)) + print("---oneline--- :") + print(printer.to_string(oneline=True)) + print("") + print("original table form:") + tb = printer.table + print(tb) + print("") + print("") + + def test_basic(self): + cube = example_cube( + n_extra_dims=4 + ) # NB does not yet work with factories. + self._exercise_methods(cube) + + def test_scalar_cube(self): + cube = example_cube()[0, 0, 0, 0] + self._exercise_methods(cube) + + +if __name__ == "__main__": + tests.main() diff --git a/lib/iris/tests/unit/representation/test_cube_summary.py b/lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.py similarity index 99% rename from lib/iris/tests/unit/representation/test_cube_summary.py rename to lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.py index bdc066c955..e97ded8c22 100644 --- a/lib/iris/tests/unit/representation/test_cube_summary.py +++ b/lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.py @@ -288,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])") diff --git a/lib/iris/tests/unit/representation/test_cube_printout.py b/lib/iris/tests/unit/representation/test_cube_printout.py deleted file mode 100644 index ddd1fb3763..0000000000 --- a/lib/iris/tests/unit/representation/test_cube_printout.py +++ /dev/null @@ -1,175 +0,0 @@ -# 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, 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_4d() # this one has a derived coord - - # Optionally : add multiple extra dimensions to test the width controls - if n_extra_dims > 0: - - 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, - ) - 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, - ) - 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"), - latlon_dims, - ) - cube.add_aux_coord( - 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(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) - - # 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 _exercise_methods(self, cube): - summ = icr.CubeSummary(cube) - printer = CubePrinter(summ) - 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 :") - if unprintable: - print(" ( would fail, due to scalar-cube with scalar-ancils )") - else: - print(cube) - print("---full--- :") - print(printer.to_string()) - print("") - print("EXISTING oneline :") - print(repr(cube)) - print("---oneline--- :") - print(printer.to_string(oneline=True)) - print("") - print("original table form:") - tb = printer.table - 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) - - -if __name__ == "__main__": - tests.main() From 6d4e07e2848f7f7cbfb9da23893f8107742f7f6c Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Wed, 10 Feb 2021 13:48:27 +0000 Subject: [PATCH 12/17] Proper unit tests; partial - more to come. --- .../representation/cube_printout/__init__.py | 6 + .../cube_printout/test_CubePrintout.py | 521 +++++++++--------- .../cube_printout/test_Table.py | 86 +++ .../representation/cube_summary/__init__.py | 6 + .../cube_summary/test_CubeSummary.py | 2 +- 5 files changed, 353 insertions(+), 268 deletions(-) create mode 100644 lib/iris/tests/unit/representation/cube_printout/__init__.py create mode 100644 lib/iris/tests/unit/representation/cube_printout/test_Table.py create mode 100644 lib/iris/tests/unit/representation/cube_summary/__init__.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 index aa11bcd42a..4791cf8ade 100644 --- a/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py +++ b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py @@ -3,305 +3,292 @@ # 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.""" - +"""Unit tests for :class:`iris._representation.cube_printout.CubePrintout`.""" import iris.tests as tests import numpy as np -import iris + from iris.cube import Cube from iris.coords import AuxCoord, DimCoord from iris._representation.cube_summary import CubeSummary -import iris.tests.stock as istk -from iris.util import new_axis -from iris._representation.cube_printout import CubePrinter, Table +from iris._representation.cube_printout import CubePrinter -class TestTable(tests.IrisTest): - 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 cube_replines(cube, **kwargs): + return CubePrinter(CubeSummary(cube)).to_string(**kwargs).split("\n") - 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 - with self.assertRaisesRegex(ValueError, "columns.*!=.*existing"): - table.add_row(["one"], ["left"]) - # Fails with bad number of aligns - with self.assertRaisesRegex(ValueError, "aligns.*!=.*col"): - table.add_row(["one", "two"], ["left"]) - - -def example_cube(n_extra_dims=0): - cube = istk.realistic_4d() # this one has a derived coord - - # Optionally : add multiple extra dimensions to test the width controls - if n_extra_dims > 0: - - 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, - ) - 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, - ) - 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"), - latlon_dims, - ) - cube.add_aux_coord( - 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(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) - - # 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 - - -def cube_repr(cube, **kwargs): - return CubePrinter(CubeSummary(cube)).to_string(**kwargs) - - -class TestCubePrintout__cubefeatures_summaries(tests.IrisTest): + +class TestCubePrintout__to_string(tests.IrisTest): def test_empty(self): cube = Cube([0]) - repr = cube_repr(cube) - self.assertEqual(repr, "unknown / (unknown) (-- : 1)") - repr = cube_repr(cube, oneline=True) - self.assertEqual(repr, "unknown / (unknown) (-- : 1)") + 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(self): + def test_scalar_cube_summaries(self): cube = Cube(0) - repr = cube_repr(cube) + rep = cube_replines(cube) self.assertEqual( - repr, "unknown / (unknown) (scalar cube)" + rep, ["unknown / (unknown) (scalar cube)"] ) - repr = cube_repr(cube, oneline=True) - self.assertEqual(repr, "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") - repr = cube_repr(cube) - self.assertEqual(repr, "cube_accel / (ms-2) (-- : 2)") - repr = cube_repr(cube, name_padding=0) - self.assertEqual(repr, "cube_accel / (ms-2) (-- : 2)") - repr = cube_repr(cube, name_padding=25) - self.assertEqual(repr, "cube_accel / (ms-2) (-- : 2)") - - def test_long_coordname_columns(self): + 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) - repr = cube_repr(cube) - expected = ( - "short / (1) (-- : 1)\n" - " Auxiliary coordinates:\n" - " very_very_very_very_very_long_coord_name x" - ) - self.assertEqual(repr, expected) - repr = cube_repr(cube, oneline=True) - self.assertEqual(repr, "short / (1) (-- : 1)") - - def test_long_attribute_columns(self): + 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" - repr = cube_repr(cube) - expected = ( - "short / (1) (-- : 1)\n" - " Attributes:\n" - " 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): + # Include : long + 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)) ) - self.assertEqual(repr, expected) + 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)) + ) -class TestCubePrintout__integration_examples(tests.IrisTest): - def _exercise_methods(self, cube): - summ = CubeSummary(cube) - printer = CubePrinter(summ) - has_scalar_ancils = any( - len(anc.cube_dims(cube)) == 0 for anc in cube.ancillary_variables() + 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\nends."), + ) + ) + rep = cube_replines(cube) + expected = [ + "name / (1) (scalar cube)", + " Scalar coordinates:", + " co 1", + " co 2", + " note='line 1\\nline 2\\nends.'", + ] + 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)) ) - unprintable = has_scalar_ancils and cube.ndim == 0 - print("EXISTING full :") - if unprintable: - print(" ( would fail, due to scalar-cube with scalar-ancils )") - else: - print(cube) - print("---full--- :") - print(printer.to_string()) - print("") - print("EXISTING oneline :") - print(repr(cube)) - print("---oneline--- :") - print(printer.to_string(oneline=True)) - print("") - print("original table form:") - tb = printer.table - print(tb) - print("") - print("") - - def test_basic(self): - cube = example_cube( - n_extra_dims=4 - ) # NB does not yet work with factories. - self._exercise_methods(cube) - - def test_scalar_cube(self): - cube = example_cube()[0, 0, 0, 0] - self._exercise_methods(cube) + 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) + # Add a pair of vector coords with same name but different attributes. + 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): + pass + + def test_section_vector_ancils(self): + pass + + def test_section_vector_cell_measures(self): + pass + + 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 + pass + + 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) + pass + + def test_section_scalar_cell_measures(self): + pass + + def test_section_scalar_cube_attributes(self): + pass + + def test_section_cube_attributes__string(self): + # incl a newline-escaped one + # incl a long (clipped) one + # CHECK THAT CLIPPED+ESCAPED WORKS (don't lose final quote) + pass + + def test_section_cube_attributes__array(self): + # incl a long one + pass if __name__ == "__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..832a9204ea --- /dev/null +++ b/lib/iris/tests/unit/representation/cube_printout/test_Table.py @@ -0,0 +1,86 @@ +# 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 + with self.assertRaisesRegex(ValueError, "columns.*!=.*existing"): + table.add_row(["one"], ["left"]) + # Fails with bad number of aligns + with self.assertRaisesRegex(ValueError, "aligns.*!=.*col"): + table.add_row(["one", "two"], ["left"]) + + +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/cube_summary/test_CubeSummary.py b/lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.py index e97ded8c22..bb2b5cde3e 100644 --- a/lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.py +++ b/lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.py @@ -3,7 +3,7 @@ # 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.""" +"""Unit tests for :class:`iris._representation.cube_summary.CubeSummary`.""" import numpy as np import iris.tests as tests From f350d1829c06294e71ca7d66e234963ccee39bab Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Wed, 10 Feb 2021 13:59:37 +0000 Subject: [PATCH 13/17] Remove spurious import. --- lib/iris/_representation/cube_summary.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/iris/_representation/cube_summary.py b/lib/iris/_representation/cube_summary.py index f5636bb9d0..ee1e1a0d55 100644 --- a/lib/iris/_representation/cube_summary.py +++ b/lib/iris/_representation/cube_summary.py @@ -8,8 +8,6 @@ """ import re -import numpy as np - import iris.util from iris.common.metadata import _hexdigest as quickhash From b16057d2d4aaa8d2b293f74221ff3c9442083e55 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Wed, 10 Feb 2021 15:57:19 +0000 Subject: [PATCH 14/17] Additional (unfinished). --- .../cube_printout/test_CubePrintout.py | 182 ++++++++++++++++-- 1 file changed, 164 insertions(+), 18 deletions(-) diff --git a/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py index 4791cf8ade..f11ca3874d 100644 --- a/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py +++ b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py @@ -9,7 +9,13 @@ import numpy as np from iris.cube import Cube -from iris.coords import AuxCoord, DimCoord +from iris.coords import ( + AuxCoord, + DimCoord, + AncillaryVariable, + CellMeasure, + CellMethod, +) from iris._representation.cube_summary import CubeSummary from iris._representation.cube_printout import CubePrinter @@ -113,7 +119,6 @@ def test_coord_distinguishing_attributes(self): self.assertEqual(rep, expected) def test_coord_extra_attributes__array(self): - # Include : long 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) @@ -193,7 +198,7 @@ def test_coord_extra_attributes__string_escaped(self): AuxCoord( [2], long_name="co", - attributes=dict(note="line 1\nline 2\nends."), + attributes=dict(note="line 1\nline 2\tends."), ) ) rep = cube_replines(cube) @@ -202,7 +207,7 @@ def test_coord_extra_attributes__string_escaped(self): " Scalar coordinates:", " co 1", " co 2", - " note='line 1\\nline 2\\nends.'", + " note='line 1\\nline 2\\tends.'", ] self.assertEqual(rep, expected) @@ -240,7 +245,6 @@ def test_coord_extra_attributes__string_overlong(self): def test_section_vector_dimcoords(self): cube = Cube(np.zeros((2, 3)), long_name="name", units=1) - # Add a pair of vector coords with same name but different attributes. cube.add_dim_coord(DimCoord([0, 1], long_name="y"), 0) cube.add_dim_coord(DimCoord([0, 1, 2], long_name="x"), 1) @@ -254,40 +258,182 @@ def test_section_vector_dimcoords(self): self.assertEqual(rep, expected) def test_section_vector_auxcoords(self): - pass + 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): - pass + 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): - pass + 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 - pass + 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) - pass + 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): - pass + cube = Cube(np.zeros((2, 3)), long_name="name", units=1) + cube.add_cell_measure(CellMeasure([0], long_name="cm")) - def test_section_scalar_cube_attributes(self): - pass + rep = cube_replines(cube) + expected = [ + "name / (1) (-- : 2; -- : 3)", + " Scalar cell measures:", + " cm", + ] + self.assertEqual(rep, expected) - def test_section_cube_attributes__string(self): - # incl a newline-escaped one - # incl a long (clipped) one - # CHECK THAT CLIPPED+ESCAPED WORKS (don't lose final quote) - pass + 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) + long_string = ( + "this is very very very very very very very " + "very very very very very very very long." + ) + 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): # incl a long one + 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) + # cm_simple = + CellMethod("min", "x") + # cm_complex = CellMethod("max", "time", "3 hrs", "with this comment") + # cm_complex = CellMethod('max', 'time', '3 hrs') + # cube.add pass From fd0050eeb87d875f9c046b521ea31f3e2142b958 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Wed, 10 Feb 2021 16:08:17 +0000 Subject: [PATCH 15/17] More tests. Everything but cell methods. --- .../cube_printout/test_CubePrintout.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py index f11ca3874d..66bf078588 100644 --- a/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py +++ b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py @@ -14,7 +14,7 @@ DimCoord, AncillaryVariable, CellMeasure, - CellMethod, + # CellMethod, ) from iris._representation.cube_summary import CubeSummary @@ -427,14 +427,12 @@ def test_section_cube_attributes__array(self): ] self.assertEqual(rep, expected) - def test_section_cell_methods(self): - # cube = Cube([0], long_name="name", units=1) - # cm_simple = - CellMethod("min", "x") - # cm_complex = CellMethod("max", "time", "3 hrs", "with this comment") - # cm_complex = CellMethod('max', 'time', '3 hrs') - # cube.add - pass + # def test_section_cell_methods(self): + # cube = Cube([0], long_name="name", units=1) + # cm_simple = CellMethod("min", "x") + # cm_complex = CellMethod("max", "time", "3 hrs", "with this comment") + # cm_complex = CellMethod('max', 'time', '3 hrs') + # cube.add if __name__ == "__main__": From 6f78658680e63196c3ddba82c54806bbc3a53f57 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Wed, 10 Feb 2021 19:33:19 +0000 Subject: [PATCH 16/17] Remaining tests -- now complete. --- .../cube_printout/test_CubePrintout.py | 37 +++++++-- .../cube_printout/test_Table.py | 82 ++++++++++++++++++- 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py index 66bf078588..f3676c0c3a 100644 --- a/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py +++ b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py @@ -14,13 +14,22 @@ DimCoord, AncillaryVariable, CellMeasure, - # CellMethod, + 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(CubeSummary(cube)).to_string(**kwargs).split("\n") @@ -427,12 +436,26 @@ def test_section_cube_attributes__array(self): ] self.assertEqual(rep, expected) - # def test_section_cell_methods(self): - # cube = Cube([0], long_name="name", units=1) - # cm_simple = CellMethod("min", "x") - # cm_complex = CellMethod("max", "time", "3 hrs", "with this comment") - # cm_complex = CellMethod('max', 'time', '3 hrs') - # cube.add + 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__": diff --git a/lib/iris/tests/unit/representation/cube_printout/test_Table.py b/lib/iris/tests/unit/representation/cube_printout/test_Table.py index 832a9204ea..89734ab878 100644 --- a/lib/iris/tests/unit/representation/cube_printout/test_Table.py +++ b/lib/iris/tests/unit/representation/cube_printout/test_Table.py @@ -74,12 +74,86 @@ def test_add_row(self): ) self.assertEqual(len(table.rows), 2) self.assertEqual(table.rows[-1].i_col_unlimited, 199) + # Fails with bad number of columns - with self.assertRaisesRegex(ValueError, "columns.*!=.*existing"): - table.add_row(["one"], ["left"]) + regex = "columns.*!=.*existing" + with self.assertRaisesRegex(ValueError, regex): + table.add_row(["1", "2"], ["left", "right"]) + # Fails with bad number of aligns - with self.assertRaisesRegex(ValueError, "aligns.*!=.*col"): - table.add_row(["one", "two"], ["left"]) + 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__": From e46dc14a3c8529414f1f7389025fe1366c6c41e2 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Mon, 21 Jun 2021 12:31:06 +0100 Subject: [PATCH 17/17] Improve/fix comments; add : prefix to attributes. --- lib/iris/_representation/cube_printout.py | 43 +++++++++++++------ .../cube_printout/test_CubePrintout.py | 26 +++++------ 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/lib/iris/_representation/cube_printout.py b/lib/iris/_representation/cube_printout.py index 6535789d9f..0c7cfeec09 100644 --- a/lib/iris/_representation/cube_printout.py +++ b/lib/iris/_representation/cube_printout.py @@ -9,6 +9,8 @@ """ from copy import deepcopy +from iris._representation.cube_summary import CubeSummary + class Table: """ @@ -129,7 +131,7 @@ def __str__(self): class CubePrinter: """ An object created from a - :class:`iris._representation.cube_summary.CubeSummary`, which provides + :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 @@ -138,12 +140,30 @@ class CubePrinter: produce cube summary strings. This class has no internal knowledge of :class:`iris.cube.Cube`, but only - of :class:`iris._representation.cube_summary.CubeSummary`. + of :class:`iris._representation.cube_or_summary.CubeSummary`. """ - def __init__(self, cube_summary): + 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( @@ -247,7 +267,7 @@ def add_scalar_row(name, value=""): 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) + 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: @@ -276,8 +296,8 @@ def _decorated_table(table, name_padding=None): # 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)". + # 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] @@ -325,17 +345,14 @@ def to_string(self, oneline=False, name_padding=35): * 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. + * name_padding (int): + The minimum width for the "name" (#0) column. + Used for multiline output only. Returns: result (string) """ - # if max_width is None: - # max_width = self.max_width - if oneline: result = self._oneline_string() else: @@ -344,5 +361,5 @@ def to_string(self, oneline=False, name_padding=35): return result def __str__(self): - """Printout of self is the full multiline string.""" + """Printout of self, as a full multiline string.""" return self.to_string() diff --git a/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py index f3676c0c3a..0fc3d02b03 100644 --- a/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py +++ b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py @@ -31,7 +31,7 @@ def test_str(self): def cube_replines(cube, **kwargs): - return CubePrinter(CubeSummary(cube)).to_string(**kwargs).split("\n") + return CubePrinter(cube).to_string(**kwargs).split("\n") class TestCubePrintout__to_string(tests.IrisTest): @@ -87,7 +87,7 @@ def test_columns_long_attribute(self): " Attributes:", ( " very_very_very_very_very_long_name " - "longish string extends beyond dim columns" + ": longish string extends beyond dim columns" ), ] self.assertEqual(rep, expected) @@ -385,19 +385,21 @@ def test_section_cube_attributes(self): expected = [ "name / (1) (-- : 1)", " Attributes:", - " list [3]", - " number 1.2", - " string four five in a string", - " z_tupular (6, (7, 8))", + " 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 @@ -405,20 +407,20 @@ def test_section_cube_attributes__string_extras(self): expected = [ "name / (1) (-- : 1)", " Attributes:", - " escaped 'escaped\\tstring'", + " escaped : 'escaped\\tstring'", ( - " long this is very very very " + " long : this is very very very " "very very very very very very very very very very..." ), ( - " long_multi 'multi\\nline, " + " 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): - # incl a long one + # 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)) @@ -428,9 +430,9 @@ def test_section_cube_attributes__array(self): expected = [ "name / (1) (-- : 1)", " Attributes:", - " array array([1.2, 3.4])", + " array : array([1.2, 3.4])", ( - " bigarray array([[ 0, 1], [ 2, 3], " + " bigarray : array([[ 0, 1], [ 2, 3], " "[ 4, 5], [ 6, 7], [ 8, 9], [10, 11], [12, 13],..." ), ]