diff --git a/lib/iris/config.py b/lib/iris/config.py index e14ac058a4..2bb82924a2 100644 --- a/lib/iris/config.py +++ b/lib/iris/config.py @@ -1,4 +1,4 @@ -# (C) British Crown Copyright 2010 - 2016, Met Office +# (C) British Crown Copyright 2010 - 2017, Met Office # # This file is part of Iris. # @@ -66,8 +66,10 @@ from __future__ import (absolute_import, division, print_function) from six.moves import (filter, input, map, range, zip) # noqa - +import six from six.moves import configparser + +import contextlib import os.path import warnings @@ -154,3 +156,106 @@ def get_dir_option(section, option, default=None): IMPORT_LOGGER = get_option(_LOGGING_SECTION, 'import_logger') + + +################# +# Runtime options + +class NetCDF(object): + """Control Iris NetCDF options.""" + + def __init__(self, conventions_override=None): + """ + Set up NetCDF processing options for Iris. + + Currently accepted kwargs: + + * conventions_override (bool): + Define whether the CF Conventions version (e.g. `CF-1.6`) set when + saving a cube to a NetCDF file should be defined by + Iris (the default) or the cube being saved. + + If `False` (the default), specifies that Iris should set the + CF Conventions version when saving cubes as NetCDF files. + If `True`, specifies that the cubes being saved to NetCDF should + set the CF Conventions version for the saved NetCDF files. + + Example usages: + + * Specify, for the lifetime of the session, that we want all cubes + written to NetCDF to define their own CF Conventions versions:: + + iris.config.netcdf.conventions_override = True + iris.save('my_cube', 'my_dataset.nc') + iris.save('my_second_cube', 'my_second_dataset.nc') + + * Specify, with a context manager, that we want a cube written to + NetCDF to define its own CF Conventions version:: + + with iris.config.netcdf.context(conventions_override=True): + iris.save('my_cube', 'my_dataset.nc') + + """ + # Define allowed `__dict__` keys first. + self.__dict__['conventions_override'] = None + + # Now set specific values. + setattr(self, 'conventions_override', conventions_override) + + def __repr__(self): + msg = 'NetCDF options: {}.' + # Automatically populate with all currently accepted kwargs. + options = ['{}={}'.format(k, v) + for k, v in six.iteritems(self.__dict__)] + joined = ', '.join(options) + return msg.format(joined) + + def __setattr__(self, name, value): + if name not in self.__dict__: + # Can't add new names. + msg = 'Cannot set option {!r} for {} configuration.' + raise AttributeError(msg.format(name, self.__class__.__name__)) + if value is None: + # Set an unset value to the name's default. + value = self._defaults_dict[name]['default'] + if self._defaults_dict[name]['options'] is not None: + # Replace a bad value with a good one if there is a defined set of + # specified good values. If there isn't, we can assume that + # anything goes. + if value not in self._defaults_dict[name]['options']: + good_value = self._defaults_dict[name]['default'] + wmsg = ('Attempting to set invalid value {!r} for ' + 'attribute {!r}. Defaulting to {!r}.') + warnings.warn(wmsg.format(value, name, good_value)) + value = good_value + self.__dict__[name] = value + + @property + def _defaults_dict(self): + # Set this as a property so that it isn't added to `self.__dict__`. + return {'conventions_override': {'default': False, + 'options': [True, False]}, + } + + @contextlib.contextmanager + def context(self, **kwargs): + """ + Allow temporary modification of the options via a context manager. + Accepted kwargs are the same as can be supplied to the Option. + + """ + # Snapshot the starting state for restoration at the end of the + # contextmanager block. + starting_state = self.__dict__.copy() + # Update the state to reflect the requested changes. + for name, value in six.iteritems(kwargs): + setattr(self, name, value) + try: + yield + finally: + # Return the state to the starting state. + self.__dict__.clear() + self.__dict__.update(starting_state) + + +netcdf = NetCDF() diff --git a/lib/iris/fileformats/netcdf.py b/lib/iris/fileformats/netcdf.py index 527ebfabb5..11565299fb 100644 --- a/lib/iris/fileformats/netcdf.py +++ b/lib/iris/fileformats/netcdf.py @@ -1,4 +1,4 @@ -# (C) British Crown Copyright 2010 - 2016, Met Office +# (C) British Crown Copyright 2010 - 2017, Met Office # # This file is part of Iris. # @@ -2244,7 +2244,12 @@ def is_valid_packspec(p): shuffle, fletcher32, contiguous, chunksizes, endian, least_significant_digit, packing=packspec) - conventions = CF_CONVENTIONS_VERSION + if iris.config.netcdf.conventions_override: + # Set to the default if custom conventions are not available. + conventions = cube.attributes.get('Conventions', + CF_CONVENTIONS_VERSION) + else: + conventions = CF_CONVENTIONS_VERSION # Perform a CF patch of the conventions attribute. cf_profile_available = (iris.site_configuration.get('cf_profile') not diff --git a/lib/iris/tests/unit/config/__init__.py b/lib/iris/tests/unit/config/__init__.py new file mode 100644 index 0000000000..dd625e1e91 --- /dev/null +++ b/lib/iris/tests/unit/config/__init__.py @@ -0,0 +1,20 @@ +# (C) British Crown Copyright 2017, Met Office +# +# This file is part of Iris. +# +# Iris is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Iris is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Iris. If not, see . +"""Unit tests for the :mod:`iris.config` module.""" + +from __future__ import (absolute_import, division, print_function) +from six.moves import (filter, input, map, range, zip) # noqa diff --git a/lib/iris/tests/unit/config/test_NetCDF.py b/lib/iris/tests/unit/config/test_NetCDF.py new file mode 100644 index 0000000000..3929319854 --- /dev/null +++ b/lib/iris/tests/unit/config/test_NetCDF.py @@ -0,0 +1,60 @@ +# (C) British Crown Copyright 2017, Met Office +# +# This file is part of Iris. +# +# Iris is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Iris is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Iris. If not, see . +"""Unit tests for the `iris.config.NetCDF` class.""" + +from __future__ import (absolute_import, division, print_function) +from six.moves import (filter, input, map, range, zip) # noqa +import six + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests + +import warnings + +import iris.config + + +class Test(tests.IrisTest): + def setUp(self): + self.options = iris.config.NetCDF() + + def test_basic(self): + self.assertFalse(self.options.conventions_override) + + def test_enabled(self): + self.options.conventions_override = True + self.assertTrue(self.options.conventions_override) + + def test_bad_value(self): + # A bad value should be ignored and replaced with the default value. + bad_value = 'wibble' + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + self.options.conventions_override = bad_value + self.assertFalse(self.options.conventions_override) + exp_wmsg = 'Attempting to set invalid value {!r}'.format(bad_value) + six.assertRegex(self, str(w[0].message), exp_wmsg) + + def test__contextmgr(self): + with self.options.context(conventions_override=True): + self.assertTrue(self.options.conventions_override) + self.assertFalse(self.options.conventions_override) + + +if __name__ == '__main__': + tests.main() diff --git a/lib/iris/tests/unit/fileformats/netcdf/test_save.py b/lib/iris/tests/unit/fileformats/netcdf/test_save.py index b1b76f56ce..936de128a6 100644 --- a/lib/iris/tests/unit/fileformats/netcdf/test_save.py +++ b/lib/iris/tests/unit/fileformats/netcdf/test_save.py @@ -1,4 +1,4 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office +# (C) British Crown Copyright 2014 - 2017, Met Office # # This file is part of Iris. # @@ -23,6 +23,7 @@ # importing anything else. import iris.tests as tests +import mock import netCDF4 as nc import numpy as np @@ -32,20 +33,48 @@ from iris.tests.stock import lat_lon_cube -class Test_attributes(tests.IrisTest): - def test_custom_conventions(self): +class Test_conventions(tests.IrisTest): + def setUp(self): + self.cube = Cube([0]) + self.custom_conventions = 'convention1 convention2' + self.cube.attributes['Conventions'] = self.custom_conventions + self.options = iris.config.netcdf + + def test_custom_conventions__ignored(self): # Ensure that we drop existing conventions attributes and replace with # CF convention. - cube = Cube([0]) - cube.attributes['Conventions'] = 'convention1 convention2' - with self.temp_filename('.nc') as nc_path: - save(cube, nc_path, 'NETCDF4') + save(self.cube, nc_path, 'NETCDF4') ds = nc.Dataset(nc_path) res = ds.getncattr('Conventions') ds.close() self.assertEqual(res, CF_CONVENTIONS_VERSION) + def test_custom_conventions__allowed(self): + # Ensure that existing conventions attributes are passed through if the + # relevant Iris option is set. + with mock.patch.object(self.options, 'conventions_override', True): + with self.temp_filename('.nc') as nc_path: + save(self.cube, nc_path, 'NETCDF4') + ds = nc.Dataset(nc_path) + res = ds.getncattr('Conventions') + ds.close() + self.assertEqual(res, self.custom_conventions) + + def test_custom_conventions__allowed__missing(self): + # Ensure the default conventions attribute is set if the relevant Iris + # option is set but there is no custom conventions attribute. + del self.cube.attributes['Conventions'] + with mock.patch.object(self.options, 'conventions_override', True): + with self.temp_filename('.nc') as nc_path: + save(self.cube, nc_path, 'NETCDF4') + ds = nc.Dataset(nc_path) + res = ds.getncattr('Conventions') + ds.close() + self.assertEqual(res, CF_CONVENTIONS_VERSION) + + +class Test_attributes(tests.IrisTest): def test_attributes_arrays(self): # Ensure that attributes containing NumPy arrays can be equality # checked and their cubes saved as appropriate.