From df44184895e1e92390d3644e188430e582c6aae3 Mon Sep 17 00:00:00 2001 From: CloudNiner Date: Wed, 14 Dec 2016 14:45:03 -0500 Subject: [PATCH] Refactor IndicatorParams to use IndicatorParam instances + validators Replaces validate_paramname() validation with IndicatorParam instances that are fully self-contained. IndicatorParam is self-documenting via its to_dict() method, and provides extensible validation via the same API as Django and DRF. While not necessarily simpler, this eases the process for adding additional params to an indicator. It is still necessary to subclass IndicatorParams, but all of the logic for new parameters is fully encapsulated in IndicatorParam. --- .../climate_change_api/climate_data/views.py | 15 +- .../indicators/abstract_indicators.py | 24 ++- .../indicators/indicators.py | 24 +-- .../climate_change_api/indicators/params.py | 195 ++++++++++-------- .../indicators/tests/test_indicator_params.py | 106 +++++++--- .../indicators/tests/test_validators.py | 68 ++++++ .../indicators/validators.py | 31 +++ 7 files changed, 328 insertions(+), 135 deletions(-) create mode 100644 django/climate_change_api/indicators/tests/test_validators.py create mode 100644 django/climate_change_api/indicators/validators.py diff --git a/django/climate_change_api/climate_data/views.py b/django/climate_change_api/climate_data/views.py index 791f1ea1..836a5484 100644 --- a/django/climate_change_api/climate_data/views.py +++ b/django/climate_change_api/climate_data/views.py @@ -2,6 +2,7 @@ import logging from django.contrib.gis.geos import Point +from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.http import HttpResponseRedirect from django.utils.http import urlencode @@ -20,7 +21,8 @@ ClimateModelSerializer, ClimateCityScenarioDataSerializer, ScenarioSerializer) -from indicators import indicator_factory, list_available_indicators, params +from indicators import indicator_factory, list_available_indicators + logger = logging.getLogger(__name__) @@ -292,8 +294,13 @@ def climate_indicator(request, *args, **kwargs): raise ParseError(detail='Must provide a valid indicator') try: indicator_class = IndicatorClass(city, scenario, parameters=request.query_params) - except params.ValidationError as e: - raise serializers.ValidationError(str(e)) + except ValidationError as e: + # If indicator class/params fails validation, return error with help text for + # as much context as possible. + return Response(OrderedDict([ + ('error', str(e)), + ('help', IndicatorClass.init_params_class().to_dict()), + ]), status=status.HTTP_400_BAD_REQUEST) data = indicator_class.calculate() return Response(OrderedDict([ @@ -301,7 +308,7 @@ def climate_indicator(request, *args, **kwargs): ('scenario', scenario.name), ('indicator', IndicatorClass.to_dict()), ('climate_models', [m.name for m in model_list]), - ('units', indicator_class.params.units), + ('units', indicator_class.params.units.value), ('data', data), ])) diff --git a/django/climate_change_api/indicators/abstract_indicators.py b/django/climate_change_api/indicators/abstract_indicators.py index 91b980f7..0580e75e 100644 --- a/django/climate_change_api/indicators/abstract_indicators.py +++ b/django/climate_change_api/indicators/abstract_indicators.py @@ -12,6 +12,7 @@ from climate_data.filters import ClimateDataFilterSet from .params import IndicatorParams from .serializers import IndicatorSerializer +from .validators import ChoicesValidator from .unit_converters import DaysUnitsMixin @@ -41,14 +42,23 @@ def __init__(self, city, scenario, parameters=None): self.city = city self.scenario = scenario - self.params = self.params_class(parameters, self.available_units, self.default_units) - self.params.validate() + self.params = self.init_params_class() + self.params.validate(parameters) self.queryset = self.get_queryset() self.queryset = self.filter_objects() self.serializer = self.serializer_class() + @classmethod + def init_params_class(cls): + """ Return the instantiated IndicatorParams object for this class + + Should not validate the IndicatorParams + + """ + return cls.params_class(cls.default_units, cls.available_units) + @classmethod def name(cls): def convert(name): @@ -80,8 +90,8 @@ def get_queryset(self): filter_set = ClimateDataFilterSet() queryset = (ClimateData.objects.filter(map_cell=self.city.map_cell) .filter(data_source__scenario=self.scenario)) - queryset = filter_set.filter_years(queryset, self.params.years) - queryset = filter_set.filter_models(queryset, self.params.models) + queryset = filter_set.filter_years(queryset, self.params.years.value) + queryset = filter_set.filter_models(queryset, self.params.models.value) return queryset def filter_objects(self): @@ -106,9 +116,9 @@ def convert_units(self, aggregations): @param aggregations list-of-dicts returned by aggregate method @returns Dict in same format as the aggregations parameter, with values converted - to `self.params.units` + to `self.params.units.value` """ - converter = self.getConverter(self.storage_units, self.params.units) + converter = self.getConverter(self.storage_units, self.params.units.value) for item in aggregations: if item['value'] is not None: item['value'] = converter(item['value']) @@ -143,7 +153,7 @@ def calculate(self): aggregations = self.convert_units(aggregations) collations = self.collate_results(aggregations) return self.serializer.to_representation(collations, - aggregations=self.params.agg) + aggregations=self.params.agg.value) class YearlyIndicator(Indicator): diff --git a/django/climate_change_api/indicators/indicators.py b/django/climate_change_api/indicators/indicators.py index f75bf384..d7207b5e 100644 --- a/django/climate_change_api/indicators/indicators.py +++ b/django/climate_change_api/indicators/indicators.py @@ -93,40 +93,40 @@ def aggregate(self): class YearlyExtremePrecipitationEvents(CountUnitsMixin, YearlyCountIndicator): label = 'Yearly Extreme Precipitation Events' description = ('Total number of times per year daily precipitation exceeds the specified ' - '(Default 99th) percentile of observations from 1960 to 1995') + 'percentile of observations from 1960 to 1995') params_class = PercentileIndicatorParams variables = ('pr',) @property def conditions(self): return {'pr__gt': F('map_cell__baseline__pr'), - 'map_cell__baseline__percentile': self.params.percentile} + 'map_cell__baseline__percentile': self.params.percentile.value} class YearlyExtremeHeatEvents(CountUnitsMixin, YearlyCountIndicator): label = 'Yearly Extreme Heat Events' description = ('Total number of times per year daily maximum temperature exceeds the specified ' - '(Default 99th) percentile of observations from 1960 to 1995') + 'percentile of observations from 1960 to 1995') params_class = PercentileIndicatorParams variables = ('tasmax',) @property def conditions(self): return {'tasmax__gt': F('map_cell__baseline__tasmax'), - 'map_cell__baseline__percentile': self.params.percentile} + 'map_cell__baseline__percentile': self.params.percentile.value} class YearlyExtremeColdEvents(CountUnitsMixin, YearlyCountIndicator): label = 'Yearly Extreme Cold Events' description = ('Total number of times per year daily minimum temperature is below the specified ' - '(Default 1st) percentile of observations from 1960 to 1995') + 'percentile of observations from 1960 to 1995') params_class = PercentileIndicatorParams variables = ('tasmin',) @property def conditions(self): return {'tasmin__lt': F('map_cell__baseline__tasmin'), - 'map_cell__baseline__percentile': self.params.percentile} + 'map_cell__baseline__percentile': self.params.percentile.value} class HeatWaveDurationIndex(YearlyMaxConsecutiveDaysIndicator): @@ -195,40 +195,40 @@ class MonthlyFrostDays(DaysUnitsMixin, MonthlyCountIndicator): class MonthlyExtremePrecipitationEvents(CountUnitsMixin, MonthlyCountIndicator): label = 'Monthly Extreme Precipitation Events' description = ('Total number of times per month daily precipitation exceeds the specified ' - '(Default 99th) percentile of observations from 1960 to 1995') + 'percentile of observations from 1960 to 1995') params_class = PercentileIndicatorParams variables = ('pr',) @property def conditions(self): return {'pr__gt': F('map_cell__baseline__pr'), - 'map_cell__baseline__percentile': self.params.percentile} + 'map_cell__baseline__percentile': self.params.percentile.value} class MonthlyExtremeHeatEvents(CountUnitsMixin, MonthlyCountIndicator): label = 'Monthly Extreme Heat Events' description = ('Total number of times per month daily maximum temperature exceeds the specified ' - '(Default 99th) percentile of observations from 1960 to 1995') + 'percentile of observations from 1960 to 1995') params_class = PercentileIndicatorParams variables = ('tasmax',) @property def conditions(self): return {'tasmax__gt': F('map_cell__baseline__tasmax'), - 'map_cell__baseline__percentile': self.params.percentile} + 'map_cell__baseline__percentile': self.params.percentile.value} class MonthlyExtremeColdEvents(CountUnitsMixin, MonthlyCountIndicator): label = 'Monthly Extreme Cold Events' description = ('Total number of times per month daily minimum temperature is below the specified ' - '(Default 1st) percentile of observations from 1960 to 1995') + 'percentile of observations from 1960 to 1995') params_class = PercentileIndicatorParams variables = ('tasmin',) @property def conditions(self): return {'tasmin__lt': F('map_cell__baseline__tasmin'), - 'map_cell__baseline__percentile': self.params.percentile} + 'map_cell__baseline__percentile': self.params.percentile.value} diff --git a/django/climate_change_api/indicators/params.py b/django/climate_change_api/indicators/params.py index 42df9cb4..ca26afac 100644 --- a/django/climate_change_api/indicators/params.py +++ b/django/climate_change_api/indicators/params.py @@ -1,100 +1,133 @@ +import logging -class ValidationError(Exception): - pass +from django.core.exceptions import ValidationError +from .validators import ChoicesValidator, percentile_range_validator -class IndicatorParams(object): - """ Superclass used to define parameters necessary for an Indicator class to function - This class defines the defaults for all indicators, which are all optional. +MODELS_PARAM_DOCSTRING = "A list of comma separated model names to filter the indicator by. The indicator values in the response will only use the selected models. If not provided, defaults to all models." + +YEARS_PARAM_DOCSTRING = "A list of comma separated year ranges to filter the response by. Defaults to all years available. A year range is of the form 'start[:end]'. Examples: '2010', '2010:2020', '2010:2020,2030', '2010:2020,2030:2040'" + +AGG_PARAM_DOCSTRING = "A list of comma separated aggregation types to return. Valid choices are 'min', 'max', 'avg', 'median', 'stddev', 'stdev', and 'XXth'. If using 'XXth', replace the XX with a number between 1-99 to return that percentile. For example, '99th' returns the value of the 99th percentile. The 'XXth' option can be provided multiple times with different values. 'stdev' is an alias to 'stddev'. Defaults to 'min,max,avg'." + +UNITS_PARAM_DOCSTRING = "Units in which to return the data. Defaults to Imperial units (Fahrenheit for temperature indicators and inches per day for precipitation)." + +PERCENTILE_PARAM_DOCSTRING = "The percentile threshold used to calculate the number of exceeding events compared to historic levels. Must be an integer in the range [0,100]." + - New IndicatorParams subclasses should implement a new validate_paramname(self, value) - method for each parameter they add. This method should: - - Return a default value if the parameter is optional - - Raise indicators.params.ValidationError(message) if the value is invalid - - Ensure the paramname is added to the self.PARAMS iterable +logger = logging.getLogger(__name__) - After calling validate(), the validated parameter values are available as instance attributes. + +class IndicatorParam(object): + """ Defines an individual query parameter for an Indicator request + + @param name: The name of the query parameter + @param description: Human readable descriptive help string describing use of the parameter + @param required: Is this a required parameter? + @param default: Default value to use for parameter if none provided + @param validators: Array of functions or classes implementing the django.core.validators + interface, used to validate the parameter. """ + def __init__(self, name, description='', required=True, default=None, validators=None): + self.name = name + self.description = description + self.required = required + self.default = default + self.validators = validators if validators is not None else [] + self.value = None + + def validate(self, value): + """ Validates the parameter by running all defined validators + + Checks if param is required even if no validators are defined. - @classmethod - def params(cls): - return ('models', 'years', 'agg', 'units',) + Raises django.core.exceptions.ValidationError on the first failed validation check. - def __init__(self, query_params, available_units, default_units): """ - @param query_params (dict[str:str]): Dict of key/value pairs to parse - @param available_units (Iterable[str]): List of unit strings this class should check the - 'units' query_params against - @param default_units (string): Default value for 'units' to set if none provided - in query_params + value = value if value is not None else self.default + if value is None and self.default is None and self.required: + raise ValidationError('{} is required.'.format(self.name)) + + for v in self.validators: + v(value) + self.value = value + + def to_dict(self): + """ Return complete representation of this class as a serializable dict + + Makes the IndicatorParam class self documenting. + """ - self._raw_params = query_params - self._available_units = available_units - self._default_units = default_units - self.validated = False - - def validate(self): - """ Validate all parameters found in self.PARAMS """ - for param in self.params(): - value = self._raw_params.get(param, None) - validation_fn = getattr(self, 'validate_' + param) - clean_value = validation_fn(value) - setattr(self, param, clean_value) - self.validated = True - - def validate_models(self, value): - return self._check_is_string('models', value) if value is not None else None - - def validate_years(self, value): - return self._check_is_string('years', value) if value is not None else None - - def validate_agg(self, value): - return self._check_is_string('agg', value) if value is not None else None - - def validate_units(self, value): - if value is None: - value = self._default_units - if value not in self._available_units: - raise ValidationError('Unit {} must be one of {}'.format(value, self._available_units)) - return value + return { + 'name': self.name, + 'description': self.description, + 'required': self.required, + 'default': self.default, + } def __repr__(self): - """ Override repr to improve class self-documentation """ - representation = { - 'validated': self.validated, - 'params': self.params, - } - if self.validated: - for p in self.params(): - representation[p] = getattr(self, p) - else: - for k, v in self._raw_params.iteritems(): - representation[k] = v - return str(representation) + return self.to_dict() + + +class IndicatorParams(object): + """ Superclass used to define parameters necessary for an Indicator class to function - def _check_is_string(self, param, value): - """ Private helper method to raise ValidationError if value is not a string """ - if not isinstance(value, basestring): - raise ValidationError('{} must be a string'.format(param)) - return value + Params can be defined either as class or instance variables. Prefer class variables + if the IndicatorParam in question has no run-time dependencies. + + """ + models = IndicatorParam('models', + description=MODELS_PARAM_DOCSTRING, + required=False, + validators=None) + years = IndicatorParam('years', + description=YEARS_PARAM_DOCSTRING, + required=False, + validators=None) + agg = IndicatorParam('agg', + description=AGG_PARAM_DOCSTRING, + required=False, + validators=None) + + def __init__(self, default_units, available_units): + """ Initialize additional params that are instance specific + + Would love a workaround so that we don't have to do this, and can define all params + statically. But, units has defaults/choices that are specific to the indicator and params + we're validating, so we can't do that here. + + """ + available_units_validator = ChoicesValidator(available_units) + self.units = IndicatorParam('units', + description=UNITS_PARAM_DOCSTRING, + required=False, + default=default_units, + validators=[available_units_validator]) + + def validate(self, parameters): + """ Validate all parameters """ + for param_class in self._get_params_classes(): + value = parameters.get(param_class.name, None) + param_class.validate(value) + + def to_dict(self): + return [c.to_dict() for c in self._get_params_classes()] + + def _get_params_classes(self): + """ Return a list of the IndicatorParam instances defined on this class """ + return sorted([getattr(self, x) for x in dir(self) + if isinstance(getattr(self, x), IndicatorParam)], + key=lambda c: c.name) + + def __repr__(self): + return self.to_dict() class PercentileIndicatorParams(IndicatorParams): """ Extend IndicatorParams with a non-optional 'percentile' parameter """ - - @classmethod - def params(cls): - return super(PercentileIndicatorParams, cls).params() + ('percentile',) - - def validate_percentile(self, value): - if value is None: - raise ValidationError('percentile param required') - try: - int_value = int(value) - except ValueError: - raise ValidationError('percentile param must be an integer') - if int_value < 0 or int_value > 100: - raise ValidationError('percentile param must be within the range [0-100]') - return int_value + percentile = IndicatorParam('percentile', + description=PERCENTILE_PARAM_DOCSTRING, + required=True, + validators=[percentile_range_validator]) diff --git a/django/climate_change_api/indicators/tests/test_indicator_params.py b/django/climate_change_api/indicators/tests/test_indicator_params.py index 1adc838c..d2af8642 100644 --- a/django/climate_change_api/indicators/tests/test_indicator_params.py +++ b/django/climate_change_api/indicators/tests/test_indicator_params.py @@ -1,72 +1,116 @@ +from django.core.exceptions import ValidationError from django.test import TestCase -from indicators import params +from indicators.params import IndicatorParam, IndicatorParams, PercentileIndicatorParams from indicators.utils import merge_dicts +from indicators.validators import ChoicesValidator -class IndicatorParamsTestCase(TestCase): +available_units_validator = ChoicesValidator(['K', 'C', 'F']) + + +class IndicatorParamTestCase(TestCase): + + def test_sets_default(self): + param = IndicatorParam('units', default='F') + param.validate('K') + self.assertEqual(param.value, 'K') + + param = IndicatorParam('units', default='F') + param.validate(None) + self.assertEqual(param.value, 'F') + + def test_required_with_default(self): + param = IndicatorParam('units', required=True, default='F') + param.validate(None) + self.assertEqual(param.value, 'F') + + def test_required_no_default(self): + param = IndicatorParam('units', required=True, default=None) + with self.assertRaises(ValidationError): + param.validate(None) + + def test_blank_validators(self): + """ should set validators to empty array if None provided """ + param = IndicatorParam('units', validators=None) + self.assertEqual(param.validators, []) + param = IndicatorParam('units') + self.assertEqual(param.validators, []) - default_parameters = {} - params_class = params.IndicatorParams + +class IndicatorParamsTestCase(TestCase): def setUp(self): - pass + self.default_parameters = {} + self.default_units = 'F' + self.available_units = ('F', 'C', 'K',) + self.params_class = IndicatorParams + + def _get_params_class(self): + return self.params_class(self.default_units, self.available_units) def test_validate_valid_only_expected_params(self): """ it should ensure indicator_params sets values for each of the base params """ parameters = merge_dicts(self.default_parameters, {'models': 'CCSM4', 'years': '2050:2060', 'units': 'K', 'agg': 'avg'}) - indicator_params = self.params_class(parameters, ('K',), 'K') - indicator_params.validate() - self.assertEqual(indicator_params.models, 'CCSM4') - self.assertEqual(indicator_params.years, '2050:2060') - self.assertEqual(indicator_params.units, 'K') - self.assertEqual(indicator_params.agg, 'avg') + indicator_params = self._get_params_class() + indicator_params.validate(parameters) + self.assertEqual(indicator_params.models.value, 'CCSM4') + self.assertEqual(indicator_params.years.value, '2050:2060') + self.assertEqual(indicator_params.units.value, 'K') + self.assertEqual(indicator_params.agg.value, 'avg') def test_validate_valid_some_unused_params(self): """ it should ensure indicator_params properly ignores params that aren't defined """ parameters = merge_dicts(self.default_parameters, {'doesnotexist': 'true', 'years': '2050:2060', 'units': 'K', 'agg': 'avg'}) - indicator_params = self.params_class(parameters, ('K',), 'K') - indicator_params.validate() + indicator_params = self._get_params_class() + indicator_params.validate(parameters) with self.assertRaises(AttributeError): value = indicator_params.doesnotexist def test_validate_valid_optional_defaults(self): - """ it should ensure indicator_params properly sets defaults on base params """ + """ it should ensure indicator_params properly sets defaults on base params + + Force units to have default value by overriding IndicatorParams + + """ parameters_sets = [ {'models': None, 'years': None, 'units': None, 'agg': None}, {} ] for parameters in parameters_sets: p = merge_dicts(self.default_parameters, parameters) - indicator_params = self.params_class(p, ('K',), 'K') - indicator_params.validate() - self.assertIsNone(indicator_params.years) - self.assertIsNone(indicator_params.models) - self.assertIsNone(indicator_params.agg) - self.assertEquals(indicator_params.units, 'K') + indicator_params = self._get_params_class() + indicator_params.validate(p) + self.assertIsNone(indicator_params.years.value) + self.assertIsNone(indicator_params.models.value) + self.assertIsNone(indicator_params.agg.value) + self.assertEqual(indicator_params.units.value, self.default_units) class PercentileIndicatorParamsTestCase(IndicatorParamsTestCase): - default_parameters = {'percentile': '95'} - params_class = params.PercentileIndicatorParams + + def setUp(self): + super(PercentileIndicatorParamsTestCase, self).setUp() + self.default_parameters = {'percentile': '95'} + self.params_class = PercentileIndicatorParams def test_validate_percentile_none(self): """ It should raise ValidationError if required param is missing """ parameters = merge_dicts(self.default_parameters, {'percentile': None}) - indicator_params = self.params_class(parameters, ('K',), 'K') - with self.assertRaises(params.ValidationError): - indicator_params.validate() + indicator_params = self._get_params_class() + with self.assertRaises(ValidationError): + indicator_params.validate(parameters) def test_validate_percentile_out_of_bounds(self): """ It should raise ValidationError if percentile outside range [0-100] """ parameters = merge_dicts(self.default_parameters, {'percentile': '-1'}) - indicator_params = self.params_class(parameters, ('K',), 'K') - with self.assertRaises(params.ValidationError): - indicator_params.validate() + indicator_params = self._get_params_class() + with self.assertRaises(ValidationError): + indicator_params.validate(parameters) parameters = merge_dicts(self.default_parameters, {'percentile': '101'}) - indicator_params = self.params_class(parameters, ('K',), 'K') - with self.assertRaises(params.ValidationError): - indicator_params.validate() + indicator_params = self._get_params_class() + with self.assertRaises(ValidationError): + indicator_params.validate(parameters) diff --git a/django/climate_change_api/indicators/tests/test_validators.py b/django/climate_change_api/indicators/tests/test_validators.py new file mode 100644 index 00000000..85bb0d10 --- /dev/null +++ b/django/climate_change_api/indicators/tests/test_validators.py @@ -0,0 +1,68 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from indicators.validators import ChoicesValidator, IntRangeValidator + +class ValidatorTestCase(TestCase): + + def should_succeed_with_value(self, validator, value): + """ Helper method to raise failure if validator(value) should succeed but fails """ + try: + validator(value) + except ValidationError as e: + self.fail(str(e)) + + +class IntRangeValidatorTestCase(ValidatorTestCase): + + def test_valid(self): + v = IntRangeValidator(minimum=0, maximum=5) + self.should_succeed_with_value(v, 3) + + def test_handle_strings(self): + v = IntRangeValidator(minimum=0, maximum=5) + self.should_succeed_with_value(v, '3') + with self.assertRaises(ValidationError): + v('notanumber') + + def test_range_edges(self): + v = IntRangeValidator(minimum=0, maximum=5) + self.should_succeed_with_value(v, 0) + self.should_succeed_with_value(v, 5) + + def test_out_of_range(self): + v = IntRangeValidator(minimum=0, maximum=5) + with self.assertRaises(ValidationError): + v(-1) + with self.assertRaises(ValidationError): + v(6) + + +class ChoicesValidatorTestCase(ValidatorTestCase): + + def setUp(self): + self.default_choices = ('a', 'b', 'c',) + + def test_valid(self): + v = ChoicesValidator(self.default_choices) + self.should_succeed_with_value(v, 'a') + + def test_invalid(self): + v = ChoicesValidator(self.default_choices) + with self.assertRaises(ValidationError): + v('notinchoices') + + def test_int_types(self): + v = ChoicesValidator((1, 2, 3,)) + self.should_succeed_with_value(v, 1) + with self.assertRaises(ValidationError): + v(5) + + def test_mixed_types(self): + v = ChoicesValidator(('a', 1,)) + self.should_succeed_with_value(v, 'a') + self.should_succeed_with_value(v, 1) + with self.assertRaises(ValidationError): + v('b') + with self.assertRaises(ValidationError): + v(5) diff --git a/django/climate_change_api/indicators/validators.py b/django/climate_change_api/indicators/validators.py new file mode 100644 index 00000000..31f36281 --- /dev/null +++ b/django/climate_change_api/indicators/validators.py @@ -0,0 +1,31 @@ +from django.core.exceptions import ValidationError + + +class IntRangeValidator(object): + + def __init__(self, minimum=0, maximum=100): + self.minimum = minimum + self.maximum = maximum + + def __call__(self, value): + try: + int_value = int(value) + except (TypeError, ValueError) as e: + raise ValidationError('{} is not an integer'.format(value)) + if int_value < self.minimum or int_value > self.maximum: + raise ValidationError('{} must be in the range [{}, {}]' + .format(value, self.minimum, self.maximum)) + + +class ChoicesValidator(object): + """ Validator that checks to ensure a value is one of a limited set of options """ + + def __init__(self, choices): + self.choices = choices + + def __call__(self, value): + if value not in self.choices: + raise ValidationError('{} must be one of {}'.format(value, self.choices)) + + +percentile_range_validator = IntRangeValidator(minimum=0, maximum=100)