Repository: ioos/compliance-checker

Issue:
CF checker not catching units error in cell_methods comment
Originally part of #501

https://drive.google.com/open?id=0B6AWgiTrQ0xFbXhXTlgzS0MwU3M
(7) There is an error in the units declared in the cell_methods comment string -- only picked up by the cf-checker.


Discussion:

Resolving patch:
diff --git a/compliance_checker/cf/cf.py b/compliance_checker/cf/cf.py
--- a/compliance_checker/cf/cf.py
+++ b/compliance_checker/cf/cf.py
@@ -6,11 +6,12 @@
 from compliance_checker.cf.appendix_f import grid_mapping_dict
 from compliance_checker.cf import util
 from compliance_checker import cfutil
+from cf_units import Unit
 from functools import wraps
 from collections import defaultdict
 import numpy as np
 import os
-import re
+import regex
 import sys
 
 import logging
@@ -387,7 +388,7 @@ def check_naming_conventions(self, ds):
             '_Unsigned'
         ]
 
-        rname = re.compile("^[A-Za-z][A-Za-z0-9_]*$")
+        rname = regex.compile("^[A-Za-z][A-Za-z0-9_]*$")
 
         for name, variable in ds.variables.items():
             variable_naming.assert_true(rname.match(name) is not None,
@@ -659,14 +660,12 @@ def _get_dimension_order(self, ds, name, coord_axis_map):
 
     def _dims_in_order(self, dimension_order):
         '''
-        Returns True if the dimensions are in order U*, T, Z, Y, X
-
         :param list dimension_order: A list of axes
         :rtype: bool
         :return: Returns True if the dimensions are in order U*, T, Z, Y, X,
                  False otherwise
         '''
-        regx = re.compile(r'^L?I?U*T?Z?(?:(?:Y?X?)|(?:C?)|(?:A+))$')
+        regx = regex.compile(r'^L?I?U*T?Z?(?:(?:Y?X?)|(?:C?)|(?:A+))$')
         dimension_string = ''.join(dimension_order)
         return regx.match(dimension_string) is not None
 
@@ -741,7 +740,7 @@ def check_conventions_are_cf_16(self, ds):
         valid = False
         reasoning = []
         if hasattr(ds, 'Conventions'):
-            conventions = re.split(',|\s+', getattr(ds, 'Conventions', ''))
+            conventions = regex.split(',|\s+', getattr(ds, 'Conventions', ''))
             for convention in conventions:
                 if convention == 'CF-1.6':
                     valid = True
@@ -896,7 +895,8 @@ def check_units(self, ds):
                 ret_val.append(valid_udunits)
 
             if isinstance(standard_name, basestring):
-                valid_standard_units = self._check_valid_standard_units(ds, name)
+                valid_standard_units = self._check_valid_standard_units(ds,
+                                                                        name)
                 ret_val.append(valid_standard_units)
 
         return ret_val
@@ -1376,7 +1376,7 @@ def _check_flag_meanings(self, ds, name):
         valid_meanings.assert_true(len(flag_meanings) > 0,
                                    "flag_meanings can't be empty")
 
-        flag_regx = re.compile("^[0-9A-Za-z_\-.+@]+$")
+        flag_regx = regex.compile("^[0-9A-Za-z_\-.+@]+$")
         meanings = flag_meanings.split()
         for meaning in meanings:
             if flag_regx.match(meaning) is None:
@@ -1785,7 +1785,7 @@ def _check_formula_terms(self, ds, coord):
         if standard_name not in dimless:
             return valid_formula_terms.to_result()
 
-        regx_match = re.match(dimless[standard_name], formula_terms)
+        regx_match = regex.match(dimless[standard_name], formula_terms)
         valid_formula_terms.assert_true(regx_match is not None,
                                         "formula_terms are invalid for {}, please see appendix D of CF 1.6"
                                         "".format(standard_name))
@@ -2589,7 +2589,7 @@ def check_cell_measures(self, ds):
                                                    c is not None)
         for var in variables:
             search_str = '^(?:area|volume): (\w+)$'
-            search_res = re.search(search_str, var.cell_measures)
+            search_res = regex.search(search_str, var.cell_measures)
             if not search_res:
                 valid = False
                 reasoning.append("The cell_measures attribute for variable {} "
@@ -2659,7 +2659,7 @@ def check_cell_methods(self, ds):
         :return: List of results
         """
 
-        methods = [
+        methods = {
             "point",
             "sum",
             "mean",
@@ -2670,13 +2670,12 @@ def check_cell_methods(self, ds):
             "variance",
             "mode",
             "median"
-        ]
+        }
 
         ret_val = []
-        # The basic format is `name: method (
-        psep = re.compile(r'((?P<var>\w+): (?P<method>\w+) ?(?P<where>where (?P<wtypevar>\w+) '
-                          '?(?P<over>over (?P<otypevar>\w+))?| ?)(?P<brace>\(((?P<brace_wunit>\w+): '
-                          '(\d+) (?P<unit>\w+)|(?P<brace_opt>\w+): (\w+))\))*)')
+        psep = regex.compile(r'((?P<vars>\w+: )+(?P<method>\w+) ?(?P<where>where (?P<wtypevar>\w+) '
+                              '?(?P<over>over (?P<otypevar>\w+))?| ?)(?P<brace>\(((?P<brace_wunit>\w+): '
+                              '(?P<interval_val>\w+) (?P<interval_unit>\w+)|(?P<brace_opt>\w+): (\w+))\))?)*')
 
         for var in ds.get_variables_by_attributes(cell_methods=lambda x: x is not None):
             if not getattr(var, 'cell_methods', ''):
@@ -2686,7 +2685,7 @@ def check_cell_methods(self, ds):
 
             valid_attribute = TestCtx(BaseCheck.HIGH,
                                       '§7.1 {} has a valid cell_methods attribute format'.format(var.name))
-            valid_attribute.assert_true(re.match(psep, method) is not None,
+            valid_attribute.assert_true(regex.match(psep, method) is not None,
                                         '"{}" is not a valid format for cell_methods attribute'
                                         ''.format(method))
             ret_val.append(valid_attribute.to_result())
@@ -2695,18 +2694,23 @@ def check_cell_methods(self, ds):
                                        '§7.3 {} has valid names in cell_methods attribute'.format(var.name))
 
             # check that the name is valid
-            for match in re.finditer(psep, method):
-                valid = False
-                if match.group('var') in var.dimensions:
-                    valid = True
-                elif match.group('var') == 'area':
-                    valid = True
-                elif match.group('var') in getattr(var, "coordinates", ""):
-                    valid = True
+            for match in regex.finditer(psep, method):
+                # it is possible to have "var1: var2: ... varn: ...", so handle
+                # that case
+                for var_raw_str in match.captures('vars'):
+                    # strip off the ' :' at the end of each match
+                    var_str = var_raw_str[:-2]
+                    if (var_str in var.dimensions or
+                        var_str == 'area' or
+                        var_str in getattr(var, "coordinates", "")):
+
+                        valid = True
+                    else:
+                        valid = False
 
-                valid_cell_names.assert_true(valid,
-                                             'cell_methods name component {} does not match a dimension, area or auxiliary coordinate'
-                                             ''.format(match.group('var')))
+                    valid_cell_names.assert_true(valid,
+                                                'cell_methods name component {} does not match a dimension, area or auxiliary coordinate'
+                                                ''.format(var_str))
 
             ret_val.append(valid_cell_names.to_result())
 
@@ -2714,7 +2718,7 @@ def check_cell_methods(self, ds):
             valid_cell_methods = TestCtx(BaseCheck.MEDIUM,
                                          '§7.3 {} has valid methods in cell_methods attribute'.format(var.name))
 
-            for match in re.finditer(psep, method):
+            for match in regex.finditer(psep, method):
                 valid_cell_methods.assert_true(match.group('method') in methods,
                                                '{}:cell_methods contains an invalid method: {}'
                                                ''.format(var.name, match.group('method')))
@@ -2724,12 +2728,29 @@ def check_cell_methods(self, ds):
             valid_modifier = TestCtx(BaseCheck.MEDIUM,
                                      '§7.3.3 {} has valid cell_methods modifiers'.format(var.name))
 
-            for match in re.finditer(psep, method):
+            for match in regex.finditer(psep, method):
                 if match.group('brace') is not None:
-                    valid_modifier.assert_true(match.group('brace_wunit') in ('interval', 'comment', 'area'),
+                    valid_modifier.assert_true(match.group('brace_wunit') in {'interval', 'comment', 'area'},
                                                '{}:cell_methods contains an invalid modifier: {}. It should be one '
                                                'of interval, comment or area.'
                                                ''.format(var.name, match.group('brace_wunit')))
+                    if match.group('brace_wunit') == 'interval':
+                        # attempt to get the number for the interval
+                        valid_modifier.out_of += 2
+                        try:
+                            float(match.group('interval_val'))
+                        except ValueError:
+                            valid_modifier.messages.append('{}:cell_methods contains an interval value that does not parse as a numeric value: "{}".'.format(var.name, match.group('interval_val')))
+                        else:
+                            valid_modifier.score += 1
+
+                        # then the units
+                        try:
+                            Unit(match.group('interval_unit'))
+                        except ValueError:
+                            valid_modifier.messages.append('{}:cell_methods interval units "{}" is not parsable by UDUNITS.'.format(var.name, match.group('interval_unit')))
+                        else:
+                            valid_modifier.score += 1
 
             if valid_modifier.out_of > 0:
                 ret_val.append(valid_modifier.to_result())
@@ -2847,7 +2868,7 @@ def check_climatological_statistics(self, ds):
         for cell_method_var in ds.get_variables_by_attributes(cell_methods=lambda s: s is not
                                                               None):
             total_climate_count += 1
-            if not re.search(re_string, cell_method_var.cell_methods):
+            if not regex.search(re_string, cell_method_var.cell_methods):
                 reasoning.append('The "time: method within years/days over years/days" format is not correct in variable {}.'.format(cell_method_var.name))
             else:
                 valid_climate_count += 1
diff --git a/compliance_checker/runner.py b/compliance_checker/runner.py
--- a/compliance_checker/runner.py
+++ b/compliance_checker/runner.py
@@ -74,10 +74,12 @@ def run_checker(cls, ds_loc, checker_names, verbose, criteria,
                                                    limit)
 
         elif output_format == 'html':
-            groups = cls.html_output(cs, score_groups, output_filename, ds_loc, limit)
+            groups = cls.html_output(cs, score_groups, output_filename, ds_loc,
+                                     limit)
 
         elif output_format == 'json':
-            groups = cls.json_output(cs, score_groups, output_filename, ds_loc, limit)
+            groups = cls.json_output(cs, score_groups, output_filename, ds_loc,
+                                     limit)
 
         else:
             raise TypeError('Invalid format %s' % output_format)
@@ -167,7 +169,7 @@ def check_errors(cls, score_groups, verbose):
         '''
         errors_occurred = False
         for checker, rpair in score_groups.items():
-            groups, errors = rpair
+            errors = rpair[-1]
             if len(errors):
                 errors_occurred = True
                 print("WARNING: The following exceptions occured during the %s checker (possibly indicate compliance checker issues):" % checker, file=sys.stderr)


Test patch:
diff --git a/compliance_checker/tests/helpers.py b/compliance_checker/tests/helpers.py
new file mode 100644
--- /dev/null
+++ b/compliance_checker/tests/helpers.py
@@ -0,0 +1,36 @@
+from netCDF4 import Dataset
+import tempfile
+
+class MockNetCDF(Dataset):
+    """
+    Wrapper object around NetCDF Dataset to write data only to memory.
+    """
+
+    def __init__(self):
+        # taken from test/tst_diskless.py NetCDF library
+        # even though we aren't persisting data to disk, the constructor
+        # requires a filename not currently in use by another Dataset object..
+        tmp_filename = tempfile.NamedTemporaryFile(suffix='.nc',
+                                                   delete=True).name
+        super(MockNetCDF, self).__init__(tmp_filename, 'w', diskless=True,
+                                         persist=False)
+
+
+class MockTimeSeries(MockNetCDF):
+    """
+    Mock time series with time dimension and time, lon, lat, and depth
+    variables defined
+    """
+    def __init__(self):
+        super(MockTimeSeries, self).__init__()
+        self.createDimension('time', 500)
+        for v in ('time', 'lon', 'lat', 'depth'):
+            self.createVariable(v, 'd', ('time',))
+
+
+class MockVariable(object):
+    '''
+    For mocking a dataset variable
+    '''
+    pass
+
diff --git a/compliance_checker/tests/test_cf.py b/compliance_checker/tests/test_cf.py
--- a/compliance_checker/tests/test_cf.py
+++ b/compliance_checker/tests/test_cf.py
@@ -8,19 +8,14 @@
 from tempfile import gettempdir
 from compliance_checker.tests.resources import STATIC_FILES
 from compliance_checker.tests import BaseTestCase
+from compliance_checker.tests.helpers import MockTimeSeries, MockVariable
 
 import os
 import re
 import sys
 import pytest
 
-
-class MockVariable(object):
-    '''
-    For mocking a dataset variable
-    '''
-    pass
-
+from operator import sub
 
 class TestCF(BaseTestCase):
 
@@ -967,6 +962,29 @@ def test_check_cell_methods(self):
         result = result_dict[u'§7.3 temperature has valid names in cell_methods attribute']
         assert result
 
+        nc_obj = MockTimeSeries()
+        nc_obj.createVariable('temperature', 'd', ('time',))
+
+        temp = nc_obj.variables['temperature']
+        temp.cell_methods = 'lat: lon: mean depth: mean (interval: 20 meters)'
+        results = self.cf.check_cell_methods(nc_obj)
+        scored, out_of, messages = self.get_results(results)
+        result_dict = {result.name: result for result in results}
+        modifier_results = result_dict[u'§7.3.3 temperature has valid cell_methods modifiers']
+        self.assertTrue(modifier_results.value == (3, 3))
+        # modify the cell methods to something invalid
+        temp.cell_methods = 'lat: lon: mean depth: mean (interval: x whizbangs)'
+        results = self.cf.check_cell_methods(nc_obj)
+        scored, out_of, messages = self.get_results(results)
+        result_dict = {result.name: result for result in results}
+        modifier_results = result_dict[u'§7.3.3 temperature has valid cell_methods modifiers']
+        self.assertFalse(modifier_results.value == (3, 3))
+        self.assertTrue('temperature:cell_methods contains an interval value that does not parse as a numeric value: "x".'
+                        in messages)
+        self.assertTrue('temperature:cell_methods interval units "whizbangs" is not parsable by UDUNITS.'
+                        in messages)
+
+
     # --------------------------------------------------------------------------------
     # Utility Method Tests
     # --------------------------------------------------------------------------------