args spec tests working with pytest-subtests

This commit is contained in:
emlys 2021-05-13 11:25:13 -06:00
parent 26fc70d891
commit dcd8271465
2 changed files with 193 additions and 154 deletions

View File

@ -13,6 +13,7 @@
Cython
virtualenv>=12.0.1
pytest
pytest-subtests
wheel>=0.27.0
pypiwin32; sys_platform == 'win32' # pip-only
setuptools>=8.0

View File

@ -58,6 +58,7 @@ class ValidateArgsSpecs(unittest.TestCase):
model = importlib.import_module(f'natcap.invest.{model_name}')
# validate that each arg meets the expected pattern
# save up errors to report at the end
for key, arg in model.ARGS_SPEC['args'].items():
with self.subTest(arg_name=key):
@ -69,9 +70,24 @@ class ValidateArgsSpecs(unittest.TestCase):
attr in arg,
f'Missing attribute "{attr}" at the top level')
self.validate(arg)
self.validate(arg, key)
def validate_permissions_value(self, permissions):
"""
Validate an rwx-style permissions string.
Args:
permissions (str): a string to validate as permissions
Returns:
None
Raises:
AssertionError if `permissions` isn't a string, if it's
an empty string, if it has any letters besides 'r', 'w', 'x',
or if it has any of those letters more than once
"""
self.assertTrue(isinstance(permissions, str))
self.assertTrue(len(permissions) > 0)
valid_letters = {'r', 'w', 'x'}
@ -80,8 +96,22 @@ class ValidateArgsSpecs(unittest.TestCase):
# should only have a letter once
valid_letters.remove(letter)
def validate(self, arg, valid_types=valid_types):
def validate(self, arg, name, valid_types=valid_types):
"""
Recursively validate nested args against the ARGS_SPEC standard.
Args:
arg (dict): any nested arg component of an ARGS_SPEC
name (str): name to use in error messages to identify the arg
valid_types (list[str]): a list of the arg types that are valid
for this nested arg (due to its parent's type).
Returns:
None
Raises:
AssertionError if the arg violates the standard
"""
valid_nested_types = {
'raster': {'number', 'code', 'ratio'},
'vector': {
@ -104,169 +134,177 @@ class ValidateArgsSpecs(unittest.TestCase):
'directory': {'raster', 'vector', 'csv', 'file', 'directory'}
}
# arg['type'] can be either a string or a set of strings
types = arg['type'] if isinstance(arg['type'], set) else [arg['type']]
attrs = set(arg.keys())
for t in types:
self.assertTrue(t in valid_types, f'{t} is an invalid type\n{arg}')
with self.subTest(nested_arg_name=name):
# arg['type'] can be either a string or a set of strings
types = arg['type'] if isinstance(arg['type'], set) else [arg['type']]
attrs = set(arg.keys())
for t in types:
self.assertTrue(t in valid_types)
if arg['type'] == 'option_string':
# option_string type should have an options property that
# describes the valid options
self.assertTrue('options' in arg)
# May be a list or dict because some option sets are self
# explanatory and others need a description
if isinstance(arg['options'], list):
for item in arg['options']:
self.assertTrue(isinstance(item, str))
elif isinstance(arg['options'], dict):
for key, val in arg['options'].items():
self.assertTrue(isinstance(key, str))
self.assertTrue(isinstance(val, str))
attrs.remove('options')
if arg['type'] == 'option_string':
# option_string type should have an options property that
# describes the valid options
self.assertTrue('options' in arg)
# May be a list or dict because some option sets are self
# explanatory and others need a description
if isinstance(arg['options'], list):
for item in arg['options']:
self.assertTrue(isinstance(item, str))
elif isinstance(arg['options'], dict):
for key, val in arg['options'].items():
self.assertTrue(isinstance(key, str))
self.assertTrue(isinstance(val, str))
attrs.remove('options')
elif t == 'number':
# number type should have a units property
self.assertTrue('units' in arg)
# Undefined units should use the custom u.none unit
self.assertTrue(isinstance(arg['units'], pint.Unit))
attrs.remove('units')
elif t == 'number':
# number type should have a units property
self.assertTrue('units' in arg)
# Undefined units should use the custom u.none unit
self.assertTrue(isinstance(arg['units'], pint.Unit))
attrs.remove('units')
elif t == 'raster':
# raster type should have a bands property that maps each band
# index to a nested type dictionary describing the band's data
self.assertTrue('bands' in arg)
self.assertTrue(isinstance(arg['bands'], dict))
for band in arg['bands']:
self.assertTrue(isinstance(band, int))
self.validate(
arg['bands'][band],
valid_types=valid_nested_types['raster'])
attrs.remove('bands')
elif t == 'vector':
# vector type should have:
# - a fields property that maps each field header to a nested
# type dictionary describing the data in that field
# - a geometries property: the set of valid geometry types
self.assertTrue('fields' in arg)
self.assertTrue(isinstance(arg['fields'], dict))
for field in arg['fields']:
self.assertTrue(isinstance(field, str))
self.validate(
arg['fields'][field],
valid_types=valid_nested_types['vector'])
self.assertTrue('geometries' in arg)
self.assertTrue(isinstance(arg['geometries'], set))
attrs.remove('fields')
attrs.remove('geometries')
elif t == 'csv':
# csv type should have a rows property, columns property, or
# neither. rows or columns properties map each expected header
# name/pattern to a nested type dictionary describing the data
# in that row/column. may have neither if the table structure
# is too complex to describe this way.
has_rows = 'rows' in arg
has_cols = 'columns' in arg
# should not have both
self.assertTrue(not (has_rows and has_cols), arg)
if has_cols or has_rows:
headers = arg['columns'] if has_cols else arg['rows']
self.assertTrue(isinstance(headers, dict))
for header in headers:
self.assertTrue(isinstance(header, str))
elif t == 'raster':
# raster type should have a bands property that maps each band
# index to a nested type dictionary describing the band's data
self.assertTrue('bands' in arg)
self.assertTrue(isinstance(arg['bands'], dict))
for band in arg['bands']:
self.assertTrue(isinstance(band, int))
self.validate(
headers[header],
valid_types=valid_nested_types['csv'])
arg['bands'][band],
f'{name}.bands.{band}',
valid_types=valid_nested_types['raster'])
attrs.remove('bands')
attrs.discard('rows')
attrs.discard('columns')
elif t == 'vector':
# vector type should have:
# - a fields property that maps each field header to a nested
# type dictionary describing the data in that field
# - a geometries property: the set of valid geometry types
self.assertTrue('fields' in arg)
self.assertTrue(isinstance(arg['fields'], dict))
for field in arg['fields']:
self.assertTrue(isinstance(field, str))
self.validate(
arg['fields'][field],
f'{name}.fields.{field}',
valid_types=valid_nested_types['vector'])
elif t == 'directory':
# directory type should have a contents property that maps each
# expected path name/pattern within the directory to a nested
# type dictionary describing the data at that filepath
self.assertTrue('contents' in arg)
self.assertTrue(isinstance(arg['contents'], dict))
for path in arg['contents']:
self.assertTrue(isinstance(path, str))
self.validate(
arg['contents'][path],
valid_types=valid_nested_types['directory'])
attrs.remove('contents')
self.assertTrue('geometries' in arg)
self.assertTrue(isinstance(arg['geometries'], set))
# iterate over the remaining attributes
# type-specific ones have been removed by this point
for attr in attrs:
if attr in {'name', 'about'}:
self.assertTrue(isinstance(arg[attr], str))
elif attr == 'required':
# required value may be True, False, or a string that can be
# parsed as a python statement that evaluates to True or False
self.assertTrue(
isinstance(arg[attr], bool) or
isinstance(arg[attr], str))
elif attr == 'type':
self.assertTrue(
isinstance(arg[attr], str) or
isinstance(arg[attr], set))
elif attr == 'validation_options':
# the allowed validation_options properties are type-specific
if arg['type'] == 'csv':
self.assertTrue(list(arg[attr].keys()) == ['excel_ok'])
self.assertTrue(isinstance(arg[attr]['excel_ok'], bool))
elif arg['type'] == 'number':
self.assertTrue(list(arg[attr].keys()) == ['expression'])
self.assertTrue(isinstance(arg[attr]['expression'], str))
elif arg['type'] == 'freestyle_string':
self.assertEqual(list(arg[attr].keys()), ['regexp'])
self.assertTrue(isinstance(arg[attr]['regexp'], dict))
self.assertEqual(
list(arg[attr]['regexp'].keys()), ['pattern'])
self.assertTrue(isinstance(
arg[attr]['regexp']['pattern'], str))
elif arg['type'] in {'raster', 'vector'}:
keys = set(arg[attr].keys())
# should have at least one key; shouldn't have
# projection_units without projected
attrs.remove('fields')
attrs.remove('geometries')
elif t == 'csv':
# csv type should have a rows property, columns property, or
# neither. rows or columns properties map each expected header
# name/pattern to a nested type dictionary describing the data
# in that row/column. may have neither if the table structure
# is too complex to describe this way.
has_rows = 'rows' in arg
has_cols = 'columns' in arg
# should not have both
self.assertTrue(not (has_rows and has_cols))
if has_cols or has_rows:
direction = 'rows' if has_rows else 'columns'
headers = arg[direction]
self.assertTrue(isinstance(headers, dict))
for header in headers:
self.assertTrue(isinstance(header, str))
self.validate(
headers[header],
f'{name}.{direction}.{header}',
valid_types=valid_nested_types['csv'])
attrs.discard('rows')
attrs.discard('columns')
elif t == 'directory':
# directory type should have a contents property that maps each
# expected path name/pattern within the directory to a nested
# type dictionary describing the data at that filepath
self.assertTrue('contents' in arg)
self.assertTrue(isinstance(arg['contents'], dict))
for path in arg['contents']:
self.assertTrue(isinstance(path, str))
self.validate(
arg['contents'][path],
f'{name}.contents.{path}',
valid_types=valid_nested_types['directory'])
attrs.remove('contents')
# iterate over the remaining attributes
# type-specific ones have been removed by this point
for attr in attrs:
if attr in {'name', 'about'}:
self.assertTrue(isinstance(arg[attr], str))
elif attr == 'required':
# required value may be True, False, or a string that can be
# parsed as a python statement that evaluates to True or False
self.assertTrue(
(keys == {'projected'}) or
(keys == {'projected', 'projection_units'}))
self.assertTrue(isinstance(
arg[attr]['projected'], bool))
if 'projection_units' in keys:
# doesn't make sense to have projection units unless
# projected is True
self.assertEqual(arg[attr]['projected'], True)
isinstance(arg[attr], bool) or
isinstance(arg[attr], str))
elif attr == 'type':
self.assertTrue(
isinstance(arg[attr], str) or
isinstance(arg[attr], set))
elif attr == 'validation_options':
# the allowed validation_options properties are type-specific
if arg['type'] == 'csv':
self.assertTrue(list(arg[attr].keys()) == ['excel_ok'])
self.assertTrue(isinstance(arg[attr]['excel_ok'], bool))
elif arg['type'] == 'number':
self.assertTrue(list(arg[attr].keys()) == ['expression'])
self.assertTrue(isinstance(arg[attr]['expression'], str))
elif arg['type'] == 'freestyle_string':
self.assertEqual(list(arg[attr].keys()), ['regexp'])
self.assertTrue(isinstance(arg[attr]['regexp'], dict))
self.assertEqual(
list(arg[attr]['regexp'].keys()), ['pattern'])
self.assertTrue(isinstance(
arg[attr]['projection_units'], pint.Unit))
elif arg['type'] == 'file':
self.assertTrue(list(arg[attr].keys()) == ['permissions'])
self.validate_permissions_value(arg[attr]['permissions'])
elif arg['type'] == 'directory':
keys = set(arg[attr].keys())
# should have at least one of 'permissions', 'exists'
self.assertTrue(len(keys) > 0)
self.assertTrue(keys.issubset({'permissions', 'exists'}))
if 'permissions' in keys:
self.validate_permissions_value(arg[attr]['permissions'])
if 'exists' in keys:
self.assertTrue(isinstance(arg[attr]['exists'], bool))
arg[attr]['regexp']['pattern'], str))
elif arg['type'] in {'raster', 'vector'}:
keys = set(arg[attr].keys())
# should have at least one key; shouldn't have
# projection_units without projected
self.assertTrue(
(keys == {'projected'}) or
(keys == {'projected', 'projection_units'}))
self.assertTrue(isinstance(
arg[attr]['projected'], bool))
if 'projection_units' in keys:
# doesn't make sense to have projection units unless
# projected is True
self.assertTrue(arg[attr]['projected'])
self.assertTrue(isinstance(
arg[attr]['projection_units'], pint.Unit))
elif arg['type'] == 'file':
self.assertTrue(list(arg[attr].keys()) == ['permissions'])
self.validate_permissions_value(
arg[attr]['permissions'])
elif arg['type'] == 'directory':
keys = set(arg[attr].keys())
# should have at least one of 'permissions', 'exists'
self.assertTrue(len(keys) > 0)
self.assertTrue(keys.issubset({'permissions', 'exists'}))
if 'permissions' in keys:
self.validate_permissions_value(
arg[attr]['permissions'])
if 'exists' in keys:
self.assertTrue(isinstance(arg[attr]['exists'], bool))
# validation options should not exist for any other types
# validation options should not exist for any other types
else:
raise AssertionError(f"{name}'s type does not allow the "
"validation_options attribute")
# args should not have any unexpected properties
else:
raise ValueError("This arg's type does not allow the "
"validation_options attribute")
# args should not have any unexpected properties
else:
raise ValueError(f'Arg has a key ({attr}) that is not '
'expected for its type')
raise AssertionError(f'{name} has a key ({attr}) that is not '
'expected for its type')
if __name__ == '__main__':