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.