Repository: NCAS-CMS/cfdm

Issue:
Consolidate functions that get & set global settings
Equivalent to NCAS-CMS/cf-python#70.

Discussion:

Patch:
diff --git a/Changelog.rst b/Changelog.rst
index 24dca59c7..61cb26149 100644
--- a/Changelog.rst
+++ b/Changelog.rst
@@ -8,6 +8,9 @@ version 1.8.6
   (https://github.com/NCAS-CMS/cfdm/issues/55)
 * Implemented the reading and writing of netCDF4 group hierarchies for
   CF-1.8 (https://github.com/NCAS-CMS/cfdm/issues/13)
+* Renamed to lower-case (but otherwise identical) names all functions which
+  get and set global constants: `cfdm.atol`, `cfdm.rtol`, `cfdm.log_level`.
+  The old names e.g. `cfdm.ATOL` remain functional as aliases.
 * New method: `cfdm.Field.nc_variable_groups`
 * New method: `cfdm.Field.nc_set_variable_groups`
 * New method: `cfdm.Field.nc_clear_variable_groups`
@@ -23,6 +26,7 @@ version 1.8.6
 * New method: `cfdm.DomainAxis.nc_clear_dimension_groups`
 * New keyword parameter to `cfdm.write`: ``group``
 * New dependency: ``netcdf_flattener>=1.1.0``
+* New function: `cfdm.configuration`
 
 version 1.8.5
 -------------
diff --git a/cfdm/__init__.py b/cfdm/__init__.py
index 4cf6a6d8e..667e2f58c 100644
--- a/cfdm/__init__.py
+++ b/cfdm/__init__.py
@@ -75,6 +75,7 @@ from .functions import (
     RTOL,
     abspath,
     atol,
+    configuration,
     environment,
     log_level,
     rtol,
diff --git a/cfdm/constants.py b/cfdm/constants.py
index ce5387caa..c19978128 100644
--- a/cfdm/constants.py
+++ b/cfdm/constants.py
@@ -6,15 +6,29 @@ from enum import Enum
 import numpy
 
 
+"""
+A dictionary of useful constants.
+
+Whilst the dictionary may be modified directly, it is safer to
+retrieve and set the values with the dedicated get-and-set functions.
+
+:Keys:
+
+    ATOL : float
+      The value of absolute tolerance for testing numerically
+      tolerant equality.
+
+    RTOL : float
+      The value of relative tolerance for testing numerically
+      tolerant equality.
+
+    LOG_LEVEL : str
+      The minimal level of seriousness for which log messages are shown.
+      See `cf.log_level`.
+"""
 CONSTANTS = {
-    # The value of absolute tolerance for testing numerically tolerant
-    # equality.
-    'RTOL': sys.float_info.epsilon,
-    # The value of relative tolerance for testing numerically tolerant
-    # equality.
     'ATOL': sys.float_info.epsilon,
-    # The minimal level of seriousness for which log messages are shown. See
-    # functions.log_level().
+    'RTOL': sys.float_info.epsilon,
     'LOG_LEVEL': logging.getLevelName(logging.getLogger().level),
 }
 
diff --git a/cfdm/functions.py b/cfdm/functions.py
index ce428a547..049cc9262 100644
--- a/cfdm/functions.py
+++ b/cfdm/functions.py
@@ -16,6 +16,128 @@ from . import (__version__,
 from .constants import CONSTANTS, ValidLogLevels
 
 
+def configuration(atol=None, rtol=None, log_level=None):
+    '''View or set any number of constants in the project-wide configuration.
+
+    Global constants that are provided in a dictionary to view, and can be set
+    in any combination, are:
+
+    * `atol`
+    * `rtol`
+    * `log_level`
+
+    These are constants that apply throughout `cfdm`, except for specific
+    functions if overriden by keyword arguments provided to those.
+
+    Note that setting a constant using this function is equivalent to setting
+    it by means of a specific function of the same name, e.g. via `cfdm.atol`,
+    but in this case mutliple constants can be set at once.
+
+    .. versionadded:: 1.8.6
+
+    .. seealso:: `atol`, `rtol`, `log_level`
+
+    :Parameters:
+
+        atol: `float`, optional
+            The new value of absolute tolerance. The default is to not
+            change the current value.
+
+        rtol: `float`, optional
+            The new value of relative tolerance. The default is to not
+            change the current value.
+
+        log_level: `str` or `int`, optional
+            The new value of the minimal log severity level. This can
+            be specified either as a string equal (ignoring case) to
+            the named set of log levels or identifier 'DISABLE', or an
+            integer code corresponding to each of these, namely:
+
+            * ``'DISABLE'`` (``0``);
+            * ``'WARNING'`` (``1``);
+            * ``'INFO'`` (``2``);
+            * ``'DETAIL'`` (``3``);
+            * ``'DEBUG'`` (``-1``).
+
+    :Returns:
+
+        `dict`
+            The value of the project-wide constants prior to the change, or
+            the current value if no new value was specified.
+
+    **Examples:**
+
+    # View the full global configuration of constants:
+    >>> cfdm.configuration()
+    {'atol': 2.220446049250313e-16,
+     'rtol': 2.220446049250313e-16,
+     'log_level': 'WARNING'}
+    # See a change in the constants reflected in the return value:
+    >>> cfdm.log_level('DEBUG')
+    'WARNING'
+    >>> cfdm.configuration()
+    {'atol': 2.220446049250313e-16,
+     'rtol': 2.220446049250313e-16,
+     'log_level': 'DEBUG'}
+
+    # Access specific values by standard Python dictionary key querying, e.g:
+    >>> cfdm.configuration()['atol']
+    2.220446049250313e-16
+    # Note the equivalency:
+    >>> cfdm.configuration()['atol'] == cfdm.atol()
+    True
+
+    # Set multiple constants at once. Note this example is equivalent to
+    # running `cfdm.atol()` and `cfdm.log_level()` separately:
+    >>> cfdm.configuration(atol=5e-14, log_level='INFO')
+    {'atol': 2.220446049250313e-16,
+     'rtol': 2.220446049250313e-16,
+     'log_level': 'DEBUG'}
+    >>> cfdm.configuration()
+    {'atol': 5e-14, 'rtol': 2.220446049250313e-16, 'log_level': 'INFO'}
+
+    # Set just one constant, here equivalent to setting it via `cfdm.rtol()`:
+    >>> cfdm.configuration(rtol=1e-17)
+    {'atol': 5e-14, 'rtol': 2.220446049250313e-16, 'log_level': 'INFO'}
+    >>> cfdm.configuration()
+    {'atol': 5e-14, 'rtol': 1e-17, 'log_level': 'INFO'}
+    '''
+    return _configuration(
+        new_atol=atol, new_rtol=rtol, new_log_level=log_level)
+
+
+def _configuration(**kwargs):
+    '''Internal helper function to provide the logic for `cfdm.configuration`.
+
+    We delegate from the user-facing `cfdm.configuration` for two main reasons:
+
+    1) to avoid a name clash there between the keyword arguments and the
+    functions which they each call (e.g. `atol` and `cfdm.atol`) which
+    would otherwise necessitate aliasing every such function name; and
+
+    2) because the user-facing function must have the appropriate keywords
+    explicitly listed, but the very similar logic applied for each keyword
+    can be consolidated by iterating over the full dictionary of input kwargs.
+
+    '''
+    old = {name.lower(): val for name, val in CONSTANTS.items()}
+    # Filter out 'None' kwargs from configuration() defaults. Note that this
+    # does not filter out '0' or 'True' values, which is important as the user
+    # might be trying to set those, as opposed to None emerging as default.
+    kwargs = {name: val for name, val in kwargs.items() if val is not None}
+
+    # Note values are the functions not the keyword arguments of same name:
+    reset_mapping = {
+        'new_atol': atol,
+        'new_rtol': rtol,
+        'new_log_level': log_level,
+    }
+    for setting_alias, new_value in kwargs.items():  # for all input kwargs...
+        reset_mapping[setting_alias](new_value)  # ...run corresponding func
+
+    return old
+
+
 def atol(*atol):
     '''The tolerance on absolute differences when testing for numerically
     tolerant equality.


Test patch:
diff --git a/cfdm/test/test_functions.py b/cfdm/test/test_functions.py
index 0dac80f61..a744b0bc8 100644
--- a/cfdm/test/test_functions.py
+++ b/cfdm/test/test_functions.py
@@ -227,6 +227,99 @@ class FunctionsTest(unittest.TestCase):
         filename = 'https://test_file.nc'
         self.assertEqual(cfdm.abspath(filename), filename)
 
+    def test_configuration(self):
+        if self.test_only and inspect.stack()[0][3] not in self.test_only:
+            return
+
+        # Test getting of all config. and store original values to test on:
+        org = cfdm.configuration()
+        self.assertIsInstance(org, dict)
+        self.assertEqual(len(org), 3)
+        org_atol = org['atol']
+        self.assertIsInstance(org_atol, float)
+        org_rtol = org['rtol']
+        self.assertIsInstance(org_rtol, float)
+        org_ll = org['log_level']  # will be 'DISABLE' as disable for test
+        self.assertIsInstance(org_ll, str)
+
+        # Store some sensible values to reset items to for testing,
+        # ensure these are kept to be different to the defaults:
+        atol_rtol_reset_value = 7e-10
+        ll_reset_value = 'DETAIL'
+
+        # Test the setting of each lone item:
+        cfdm.configuration(atol=atol_rtol_reset_value)
+        post_set = cfdm.configuration()
+        self.assertEqual(post_set['atol'], atol_rtol_reset_value)
+        self.assertEqual(post_set['rtol'], org_rtol)
+        self.assertEqual(post_set['log_level'], org_ll)
+        cfdm.configuration(atol=org_atol)  # reset to org
+
+        cfdm.configuration(rtol=atol_rtol_reset_value)
+        post_set = cfdm.configuration()
+        self.assertEqual(post_set['atol'], org_atol)
+        self.assertEqual(post_set['rtol'], atol_rtol_reset_value)
+        self.assertEqual(post_set['log_level'], org_ll)
+        # don't reset to org this time to test change persisting...
+
+        # Note setting of previous items persist, e.g. atol above
+        cfdm.configuration(log_level=ll_reset_value)
+        post_set = cfdm.configuration()
+        self.assertEqual(post_set['atol'], org_atol)
+        self.assertEqual(
+            post_set['rtol'], atol_rtol_reset_value)  # since changed it above
+        self.assertEqual(post_set['log_level'], ll_reset_value)
+
+        # Test the setting of more than one, but not all, items simultaneously:
+        new_atol_rtol_reset_value = 5e-18
+        new_ll_reset_value = 'DEBUG'
+        cfdm.configuration(
+            rtol=new_atol_rtol_reset_value, log_level=new_ll_reset_value)
+        post_set = cfdm.configuration()
+        self.assertEqual(post_set['atol'], org_atol)
+        self.assertEqual(post_set['rtol'], new_atol_rtol_reset_value)
+        self.assertEqual(post_set['log_level'], new_ll_reset_value)
+
+        # Test setting all possible items simultaneously (to originals):
+        cfdm.configuration(
+            atol=org_atol,  # same as current setting, testing on 'no change'
+            rtol=org_rtol,
+            log_level=org_ll
+        )
+        post_set = cfdm.configuration()
+        self.assertEqual(post_set['atol'], org_atol)
+        self.assertEqual(post_set['rtol'], org_rtol)
+        self.assertEqual(post_set['log_level'], org_ll)
+
+        # Test edge cases & invalid inputs...
+        # ... 1. User might set '0' or 'True' in some cases, which is
+        # somewhat a risk area for error as 0 is Falsy & True a Bool:
+        cfdm.configuration(rtol=0, atol=0.0, log_level=0)
+        post_set = cfdm.configuration()
+        self.assertEqual(post_set['atol'], 0.0)
+        self.assertEqual(post_set['rtol'], 0.0)
+        self.assertEqual(post_set['log_level'], 'DISABLE')  # DISABLE == 0
+        cfdm.configuration(log_level=True)  # deprecated but valid value
+        # Deprecated True is converted to value 'WARNING' by log_level
+        self.assertEqual(cfdm.configuration()['log_level'], 'WARNING')
+
+        # 2. None as an input kwarg rather than as a default:
+        cfdm.configuration(atol=None, log_level=None, rtol=org_rtol)
+        post_set = cfdm.configuration()
+        self.assertEqual(post_set['atol'], 0.0)  # 0.0 as set above
+        self.assertEqual(post_set['rtol'], org_rtol)
+        self.assertEqual(post_set['log_level'], 'WARNING')  # as set above
+
+        # 3. Gracefully error with useful error messages with invalid inputs:
+        with self.assertRaises(ValueError):
+            cfdm.configuration(rtol='bad')
+        with self.assertRaises(ValueError):
+            cfdm.configuration(log_level=7)
+
+        # 4. Check invalid kwarg given logic processes **kwargs:
+        with self.assertRaises(TypeError):
+            cfdm.configuration(bad_kwarg=1e-15)
+
 #    def test_default_netCDF_fill_values(self):
 #        if self.test_only and inspect.stack()[0][3] not in self.test_only:
 #            return