def schema_config(self): return Schema({ Required('decrease_credit_after', default=1): Positive(int), Required('decrease_credit_steps', default=4): Positive(int), Required('minimum_credit', default=0.2): Any(All(float, Range(0, 1)), 0, 1) })
def schema_config(self): """Define the configuration options for IntegralGrader""" # Construct the default AbstractGrader schema schema = super(IntegralGrader, self).schema_config default_input_positions = { 'lower': 1, 'upper': 2, 'integrand': 3, 'integration_variable': 4 } # Append options return schema.extend({ Required('answers'): { Required('lower'): str, Required('upper'): str, Required('integrand'): str, Required('integration_variable'): str }, Required('input_positions', default=default_input_positions): { Required('lower', default=None): Any(None, Positive(int)), Required('upper', default=None): Any(None, Positive(int)), Required('integrand', default=None): Any(None, Positive(int)), Required('integration_variable', default=None): Any(None, Positive(int)), }, Required('integrator_options', default={'full_output': 1}): { Required('full_output', default=1): 1, Extra: object }, Required('complex_integrand', default=False): bool, # Most of the below are copied from FormulaGrader Required('user_functions', default={}): { Extra: Any(is_callable, [is_callable], FunctionSamplingSet) }, Required('user_constants', default={}): { Extra: Number }, Required('blacklist', default=[]): [str], Required('whitelist', default=[]): [Any(str, None)], Required('tolerance', default='0.01%'): Any(PercentageString, NonNegative(Number)), Required('case_sensitive', default=True): bool, Required('samples', default=1): Positive(int), # default changed to 1 Required('variables', default=[]): [str], Required('sample_from', default={}): dict, Required('failable_evals', default=0): NonNegative(int) })
def schema_config(self): """Define the configuration options for IntegralGrader""" # Construct the default AbstractGrader schema schema = super(IntegralGrader, self).schema_config default_input_positions = { 'lower': 1, 'upper': 2, 'integrand': 3, 'integration_variable': 4 } # Append options return schema.extend({ Required('answers'): { Required('lower'): str, Required('upper'): str, Required('integrand'): str, Required('integration_variable'): str }, Required('input_positions', default=default_input_positions): { Required('lower', default=None): Any(None, Positive(int)), Required('upper', default=None): Any(None, Positive(int)), Required('integrand', default=None): Any(None, Positive(int)), Required('integration_variable', default=None): Any(None, Positive(int)), }, Required('integrator_options', default={'full_output': 1}): { Required('full_output', default=1): 1, Extra: object }, Required('complex_integrand', default=False): bool, # Most of the below are copied from FormulaGrader Required('user_functions', default={}): schema_user_functions, Required('user_constants', default={}): validate_user_constants(Number), # Blacklist/Whitelist have additional validation that can't happen here, because # their validation is correlated with each other Required('blacklist', default=[]): [str], Required('whitelist', default=[]): Any(All([None], Length(min=1, max=1)), [str]), Required('tolerance', default='0.01%'): Any(PercentageString, NonNegative(Number)), Required('samples', default=1): Positive(int), # default changed to 1 Required('variables', default=[]): All([str], all_unique), Required('sample_from', default={}): dict, Required('failable_evals', default=0): NonNegative(int) })
def schema_config(self): """Define the configuration options for FormulaGrader""" # Construct the default ItemGrader schema schema = super(FormulaGrader, self).schema_config # Append options forbidden_default = "Invalid Input: This particular answer is forbidden" return schema.extend({ Required('user_functions', default={}): schema_user_functions, Required('user_constants', default={}): validate_user_constants( Number, MathArray), # Blacklist/Whitelist have additional validation that can't happen here, because # their validation is correlated with each other Required('blacklist', default=[]): [str], Required('whitelist', default=[]): Any( All([None], Length(min=1, max=1)), [str] ), Required('forbidden_strings', default=[]): [str], Required('forbidden_message', default=forbidden_default): str, Required('required_functions', default=[]): [str], Required('tolerance', default='0.01%'): Any(PercentageString, NonNegative(Number)), Required('metric_suffixes', default=False): bool, Required('samples', default=5): Positive(int), Required('variables', default=[]): All([str], all_unique), Required('numbered_vars', default=[]): All([str], all_unique), Required('sample_from', default={}): dict, Required('failable_evals', default=0): NonNegative(int), Required('max_array_dim', default=0): NonNegative(int) })
def schema_config(self): """Define the configuration options for ListGrader""" # Construct the default AbstractGrader schema schema = super(ListGrader, self).schema_config # Append options return schema.extend({ Required('ordered', default=False): bool, Required('subgraders'): Any(AbstractGrader, [AbstractGrader]), Required('grouping', default=[]): [Positive(int)], Required('answers', default=[]): Any(list, (list,)) # Allow for a tuple of lists })
def schema_config(self): """Define the configuration options for IntegralGrader""" # Construct the default AbstractGrader schema schema = super(IntegralGrader, self).schema_config # Apply the default math schema schema = schema.extend(self.math_config_options) # Append IntegralGrader-specific options default_input_positions = { 'lower': 1, 'upper': 2, 'integrand': 3, 'integration_variable': 4 } return schema.extend({ Required('answers'): { Required('lower'): text_string, Required('upper'): text_string, Required('integrand'): text_string, Required('integration_variable'): text_string }, Required('input_positions', default=default_input_positions): { Required('lower', default=None): Any(None, Positive(int)), Required('upper', default=None): Any(None, Positive(int)), Required('integrand', default=None): Any(None, Positive(int)), Required('integration_variable', default=None): Any(None, Positive(int)), }, Required('integrator_options', default={'full_output': 1}): { Required('full_output', default=1): 1, Extra: object }, Required('complex_integrand', default=False): bool, Required('samples', default=1): Positive(int), # default changed to 1 })
class AbstractSquareMatrices(VariableSamplingSet): # pylint: disable=abstract-method """ Abstract class representing a collection of square matrices Config: ======= dimension: Positive integer that specifies the dimension of the matrix (default 2) """ # This is an abstract base class __metaclass__ = abc.ABCMeta # Store the dimension of the square matrix schema_config = Schema({Required('dimension', default=2): Positive(int)})
def schema_config(self): """Define the configuration options for SumGrader""" # Construct the default AbstractGrader schema schema = super(SumGrader, self).schema_config # Apply the default math schema schema = schema.extend(self.math_config_options) # Append SumGrader-specific options default_input_positions = { 'lower': 1, 'upper': 2, 'summand': 3, 'summation_variable': 4 } return schema.extend({ Required('answers'): { Required('lower'): text_string, Required('upper'): text_string, Required('summand'): text_string, Required('summation_variable'): text_string }, Required('input_positions', default=default_input_positions): { Required('lower', default=None): Any(None, Positive(int)), Required('upper', default=None): Any(None, Positive(int)), Required('summand', default=None): Any(None, Positive(int)), Required('summation_variable', default=None): Any(None, Positive(int)), }, Required('infty_val', default=1e3): Positive(Number), Required('infty_val_fact', default=80): Positive(Number), Required('even_odd', default=0): Any(0, 1, 2), Required('samples', default=2): Positive(int), # default changed to 2 Required('tolerance', default=1e-12): Any(PercentageString, NonNegative(Number)), # default changed to 1e-12 })
def schema_config(self): """Define the configuration options for FormulaGrader""" # Construct the default ItemGrader schema schema = super(FormulaGrader, self).schema_config # Append options forbidden_default = "Invalid Input: This particular answer is forbidden" return schema.extend({ Required('user_functions', default={}): {Extra: Any(is_callable, [is_callable], FunctionSamplingSet)}, Required('user_constants', default={}): {Extra: Number}, Required('blacklist', default=[]): [str], Required('whitelist', default=[]): [Any(str, None)], Required('forbidden_strings', default=[]): [str], Required('forbidden_message', default=forbidden_default): str, Required('required_functions', default=[]): [str], Required('tolerance', default='0.01%'): Any(PercentageString, NonNegative(Number)), Required('case_sensitive', default=True): bool, Required('metric_suffixes', default=False): bool, Required('samples', default=5): Positive(int), Required('variables', default=[]): [str], Required('sample_from', default={}): dict, Required('failable_evals', default=0): NonNegative(int) })
class RandomFunction(FunctionSamplingSet): # pylint: disable=too-few-public-methods """ Generates a random well-behaved function on demand. Currently implemented as a sum of trigonometric functions with random amplitude, frequency and phase. You can control the center and amplitude of the resulting oscillations by specifying center and amplitude. The complex flag allows you to generate complex random functions. Config: input_dim (int): Number of input arguments. 1 is a unary function (default 1) output_dim (int): Number of output dimensions. 1 = scalar, more than 1 is a vector (default 1) num_terms (int): Number of random sinusoid terms to add together (default 3) center (float): Center around which oscillations occur (default 0) amplitude (float): Maximum amplitude of the function (default 10) complex (bool): Generate a complex random function (default False) Usage ===== Generate a random continous function >>> funcs = RandomFunction() By default, the generated functions are R-->R. You can specify the input and output dimensions: >>> funcs = RandomFunction(input_dim=3, output_dim=2) To control the range of the function, specify a center and amplitude. The bounds of the function will be center - amplitude < func(x) < center + amplitude. The following will give oscillations between 0 and 1. >>> funcs = RandomFunction(center=0.5, amplitude=0.5) """ schema_config = Schema({ Required('input_dim', default=1): Positive(int), Required('output_dim', default=1): Positive(int), Required('num_terms', default=3): Positive(int), Required('center', default=0): Number, Required('amplitude', default=10): Positive(Number), Required('complex', default=False): bool }) def gen_sample(self): """ Returns a randomly chosen 'nice' function. The output is a vector with output_dim dimensions: Y^i = sum_{jk} A^i_{jk} sin(B^i_{jk} X_k + C^i_{jk}) i ranges from 1 to output_dim j ranges from 1 to num_terms k ranges from 1 to input_dim """ # Generate arrays of random values for A, B and C output_dim = self.config['output_dim'] input_dim = self.config['input_dim'] num_terms = self.config['num_terms'] # Amplitudes A range from 0.5 to 1 A = np.random.rand(output_dim, num_terms, input_dim) / 2 + 0.5 # If we're complex, multiply the amplitude by a complex phase if self.config['complex']: complexphases = np.random.rand(output_dim, num_terms, input_dim) * np.pi * 2j A = A * np.exp(complexphases) # Angular frequencies B range from -pi to pi B = 2 * np.pi * (np.random.rand(output_dim, num_terms, input_dim) - 0.5) # Phases C range from 0 to 2*pi C = 2 * np.pi * np.random.rand(output_dim, num_terms, input_dim) def random_function(*args): """Function that generates the random values""" # Check that the dimensions are correct if len(args) != input_dim: msg = "Expected {} arguments, but received {}".format( input_dim, len(args)) raise ConfigError(msg) # Turn the inputs into an array xvec = np.array(args) # Repeat it into the shape of A, B and C xarray = np.tile(xvec, (output_dim, num_terms, 1)) # Compute the output matrix output = A * np.sin(B * xarray + C) # Sum over the j and k terms # We have an old version of numpy going here, so we can't use # fullsum = np.sum(output, axis=(1, 2)) fullsum = np.sum(np.sum(output, axis=2), axis=1) # Scale and translate to fit within center and amplitude fullsum = fullsum * self.config["amplitude"] / self.config[ "num_terms"] fullsum += self.config["center"] # Return the result return MathArray(fullsum) if output_dim > 1 else fullsum[0] # Tag the function with the number of required arguments random_function.nin = input_dim return random_function
class MathMixin(object): """This is a mixin class that provides generic math handling capabilities""" # Set up a bunch of defaults default_variables = DEFAULT_VARIABLES.copy() default_functions = DEFAULT_FUNCTIONS.copy() default_suffixes = DEFAULT_SUFFIXES.copy() # Set up some debugging templates debug_appendix_eval_header_template = ( "\n" "==============================================================\n" "{grader} Debug Info\n" "==============================================================\n" "Functions available during evaluation and allowed in answer:\n" "{functions_allowed}\n" "Functions available during evaluation and disallowed in answer:\n" "{functions_disallowed}\n") debug_appendix_comparison_template = ( "\n" "==========================================\n" "Comparison Data for All {samples_total} Samples\n" "==========================================\n" "Comparer Function: {comparer}\n" "Comparison Results:\n" "{comparer_results}\n" "") # Set up the comparison utilities Utils = namedtuple('Utils', ['tolerance', 'within_tolerance']) def get_comparer_utils(self): """Get the utils for comparer function.""" def _within_tolerance(x, y): return within_tolerance(x, y, self.config['tolerance']) return self.Utils(tolerance=self.config['tolerance'], within_tolerance=_within_tolerance) # Set up a bunch of configuration options math_config_options = { Required('user_functions', default={}): schema_user_functions, Required('user_constants', default={}): validate_user_constants(Number, MathArray), # Blacklist/Whitelist have additional validation that can't happen here, because # their validation is correlated with each other Required('blacklist', default=[]): [text_string], Required('whitelist', default=[]): Any(All([None], Length(min=1, max=1)), [text_string]), Required('tolerance', default='0.01%'): Any(PercentageString, NonNegative(Number)), Required('samples', default=5): Positive(int), Required('variables', default=[]): All([text_string], all_unique), Required('numbered_vars', default=[]): All([text_string], all_unique), Required('sample_from', default={}): dict, Required('failable_evals', default=0): NonNegative(int), Required('forbidden_strings', default=[]): [text_string], Required('forbidden_message', default="Invalid Input: This particular answer is forbidden"): text_string, Required('metric_suffixes', default=False): bool, Required('required_functions', default=[]): [text_string], Required('instructor_vars', default=[]): [text_string], } def validate_math_config(self): """Performs generic math configuration validation""" validate_blacklist_whitelist_config(self.default_functions, self.config['blacklist'], self.config['whitelist']) warn_if_override(self.config, 'variables', self.default_variables) warn_if_override(self.config, 'numbered_vars', self.default_variables) warn_if_override(self.config, 'user_constants', self.default_variables) warn_if_override(self.config, 'user_functions', self.default_functions) validate_no_collisions(self.config, keys=['variables', 'user_constants']) self.permitted_functions = get_permitted_functions( self.default_functions, self.config['whitelist'], self.config['blacklist'], self.config['user_functions']) # Set up the various lists we use self.functions, self.random_funcs = construct_functions( self.default_functions, self.config["user_functions"]) self.constants = construct_constants(self.default_variables, self.config["user_constants"]) self.suffixes = construct_suffixes(self.default_suffixes, self.config["metric_suffixes"]) # Construct the schema for sample_from # First, accept all VariableSamplingSets # Then, accept any list that RealInterval can interpret # Finally, single numbers or tuples of numbers will be handled by DiscreteSet schema_sample_from = Schema({ Required(varname, default=RealInterval()): Any(VariableSamplingSet, All(list, Coerce(RealInterval)), Coerce(DiscreteSet)) for varname in (self.config['variables'] + self.config['numbered_vars']) }) self.config['sample_from'] = schema_sample_from( self.config['sample_from']) # Note that voluptuous ensures that there are no orphaned entries in sample_from def check_math_response(self, answer, student_input, **kwargs): """Check the student response against a given answer""" result, used_funcs = self.raw_check(answer, student_input, **kwargs) if result['ok'] is True or result['ok'] == 'partial': self.post_eval_validation(student_input, used_funcs) return result def post_eval_validation(self, expr, used_funcs): """Runs several post-evaluation validator functions""" validate_forbidden_strings_not_used(expr, self.config['forbidden_strings'], self.config['forbidden_message']) validate_required_functions_used(used_funcs, self.config['required_functions']) validate_only_permitted_functions_used(used_funcs, self.permitted_functions) @staticmethod def get_used_vars(expressions): """ Get the variables used in expressions Arguments: expressions: an iterable collection of expressions Returns: vars_used ({str}): set of variables used """ is_empty = lambda x: x is None or x.strip() == '' expressions = [expr for expr in expressions if not is_empty(expr)] # Pre-parse all expressions (these all get cached) parsed_expressions = [parse(expr) for expr in expressions] # Create a list of all variables used in the expressions vars_used = set().union( *[p.variables_used for p in parsed_expressions]) return vars_used def gen_var_and_func_samples(self, *args): """ Generate a list of variable/function sampling dictionaries from the supplied arguments. Arguments may be strings, lists of strings, or dictionaries with string values. Does not flag any bad variables. """ # Make a list of all expressions to check for variables expressions = [] for entry in args: if isinstance(entry, six.text_type): expressions.append(entry) elif isinstance(entry, list): expressions += entry elif isinstance(entry, dict): expressions += [v for k, v in entry.items()] # Generate the variable list variables, sample_from_dict = self.generate_variable_list(expressions) # Generate the samples var_samples = gen_symbols_samples(variables, self.config['samples'], sample_from_dict, self.functions, self.suffixes, self.constants) func_samples = gen_symbols_samples(list(self.random_funcs.keys()), self.config['samples'], self.random_funcs, self.functions, self.suffixes, {}) return var_samples, func_samples def generate_variable_list(self, expressions): """ Generates the list of variables required to perform a comparison and the corresponding sampling dictionary, taking into account any numbered variables. Bad variables are not flagged here. Returns variable_list, sample_from_dict """ vars_used = self.get_used_vars(expressions) # Seed the variables list with all allowed variables variable_list = list(self.config['variables']) # Make a copy of the sample_from dictionary, so we can add numbered variables to it sample_from_dict = self.config['sample_from'].copy() # Find all unassigned variables bad_vars = set(var for var in vars_used if var not in variable_list) # Check to see if any unassigned variables are numbered_vars regexp = numbered_vars_regexp(self.config['numbered_vars']) for var in bad_vars: match = regexp.match(var) # Returns None if no match if match: # This variable is a numbered_variable # Go and add it to variable_list with the appropriate sampler (full_string, head) = match.groups() variable_list.append(full_string) sample_from_dict[full_string] = sample_from_dict[head] return variable_list, sample_from_dict def log_comparison_info(self, comparer, comparer_results): """Add sample comparison information to debug log""" msg = self.debug_appendix_comparison_template.format( samples_total=self.config['samples'], comparer=re.sub(r"0x[0-9a-fA-F]+", "0x...", six.text_type(comparer)), comparer_results=pprint.pformat(comparer_results)) msg = msg.replace("<", "<").replace(">", ">") self.log(msg) @staticmethod def consolidate_results(results, answer, failable_evals): """ Consolidate comparer result(s) into just one result. Arguments: results: a list of results dicts answer (dict): correctness data for the expected answer, or None for all correct failable_evals: int """ # Handle an empty answer if answer is None: answer = {'ok': True, 'grade_decimal': 1, 'msg': ''} # answer can contain extra keys, so prune them pruned_answer = { key: answer[key] for key in ['ok', 'grade_decimal', 'msg'] } # Check each result for correctness num_failures = 0 for result in results: if result['ok'] != True: num_failures += 1 if len(results) == 1 or num_failures > failable_evals: return result # This response appears to agree with the expected answer return pruned_answer def compare_evaluations(self, compare_params_evals, student_evals, comparer, utils): """ Compare the student evaluations to the expected results. """ results = [] if isinstance(comparer, CorrelatedComparer): result = comparer(compare_params_evals, student_evals, utils) results.append(ItemGrader.standardize_cfn_return(result)) else: for compare_params_eval, student_eval in zip( compare_params_evals, student_evals): result = comparer(compare_params_eval, student_eval, utils) results.append(ItemGrader.standardize_cfn_return(result)) # TODO: Take out this if statement - should always work. # However, presently doesn't, because subgraders don't have access to the master debuglog. if self.config['debug']: self.log_comparison_info(comparer, results) return results def log_eval_info(self, index, varlist, funclist, **kwargs): """Add sample information to debug log""" if index == 0: header = self.debug_appendix_eval_header_template.format( grader=self.__class__.__name__, # The regexp replaces memory locations, e.g., 0x10eb1e848 -> 0x... functions_allowed=pprint.pformat({ f: funclist[f] for f in funclist if f in self.permitted_functions }), functions_disallowed=pprint.pformat({ f: funclist[f] for f in funclist if f not in self.permitted_functions }), ) header = re.sub(r"0x[0-9a-fA-F]+", "0x...", header) header = header.replace('RandomFunction.gen_sample.<locals>.', '') header = header.replace("<", "<").replace(">", ">") self.log(header) msg = self.debug_appendix_eval_template.format( sample_num=index + 1, # to account for 0 index samples_total=self.config['samples'], variables=pprint.pformat(varlist), **kwargs) msg = msg.replace("<", "<").replace(">", ">") self.log(msg)
class SpecifyDomain(ObjectWithSchema): """ An author-facing, configurable decorator that validates the inputs of the decorated function. Performs shape validation and number-of-arguments validation. Configuration ============= - input_shapes (list): A list of shapes for the function inputs, where: 1: means input is scalar k, positive integer: means input is a k-component vector [k1, k2, ...], list of positive integers: means input is an array of shape (k1, k2, ...) (k1, k2, ...), tuple of positive integers: means input is an array of shape (k1, k2, ...) 'square' indicates a square matrix of any dimension - display_name (?str): Function name to be used in error messages defaults to None, meaning that the function's __name__ attribute is used. - min_length (None or int): If None, inputs must match the exact shape specified by input_shapes. If a positive integer, input_shapes must have a single entry, and the function will accept any number of entries with that shape (minimum min_length). (default None) Basic Usage: ============ Validate that both inputs to a cross product function are 3-component MathArrays: >>> import numpy as np # just to get the cross product >>> @SpecifyDomain(input_shapes=[3, 3]) ... def cross(u, v): ... return np.cross(u, v) If inputs are valid, the function is calculated: >>> a = MathArray([2, -1, 3]) >>> b = MathArray([-1, 4, 1]) >>> np.array_equal( ... cross(a, b), ... np.cross(a, b) ... ) True If inputs are bad, student-facing ArgumentShapeErrors are thrown: >>> a = MathArray([2, -1, 3]) >>> b = MathArray([-1, 4]) >>> try: ... cross(a, b) ... except ArgumentShapeError as error: ... print(error) There was an error evaluating function cross(...) 1st input is ok: received a vector of length 3 as expected 2nd input has an error: received a vector of length 2, expected a vector of length 3 >>> try: ... cross(a) ... except ArgumentError as error: ... print(error) Wrong number of arguments passed to cross(...): Expected 2 inputs, but received 1. To specify that an input should be a an array of specific size, use a list or tuple for that shape value. Below, [3, 2] specifies a 3 by 2 matrix (the tuple (3, 2) would work also). Use 'square' to indicate square matrix of any dimension: >>> @SpecifyDomain(input_shapes=[1, [3, 2], 2, 'square']) ... def f(w, x, y, z): ... pass # implement complicated stuff here >>> square_mat = MathArray([[1, 2], [3, 4]]) >>> try: ... f(1, 2, 3, square_mat) ... except ArgumentShapeError as error: ... print(error) There was an error evaluating function f(...) 1st input is ok: received a scalar as expected 2nd input has an error: received a scalar, expected a matrix of shape (rows: 3, cols: 2) 3rd input has an error: received a scalar, expected a vector of length 2 4th input is ok: received a square matrix as expected To specify that a function should be allowed to take any number of inputs of a certain shape, input_shapes should have a single entry, and set min_length to the minimum number of arguments (at least 1). >>> @SpecifyDomain(input_shapes=[1], min_length=2) # Any number of scalars ... def my_min(*args): ... return min(*args) >>> my_min(1, 2, 3) 1 >>> my_min(2, 3, 4, 5, 6, 7, 8) 2 >>> try: ... my_min(MathArray([[1, 1], [0, 1]]), 2, 3, 4) ... except ArgumentShapeError as error: ... print(error) There was an error evaluating function my_min(...) 1st input has an error: received a matrix of shape (rows: 2, cols: 2), expected a scalar 2nd input is ok: received a scalar as expected 3rd input is ok: received a scalar as expected 4th input is ok: received a scalar as expected """ schema_config = Schema({ Required('input_shapes'): [Schema(Any(is_shape_specification(), 'square'))], Required('display_name', default=None): Nullable(text_string), Required('min_length', default=None): Nullable(Positive(int)) }) def __init__(self, config=None, **kwargs): super(SpecifyDomain, self).__init__(config, **kwargs) shapes = self.config['input_shapes'] # Check that min_length is compatible with the provided shapes if self.config['min_length'] is not None and len(shapes) != 1: raise ConfigError( "SpecifyDomain was called with a specified min_length, which " "requires input_shapes to specify only a single shape. " "However, {} shapes were provided.".format(len(shapes))) self.decorate = self.make_decorator( *shapes, display_name=self.config['display_name'], min_length=self.config['min_length']) def __call__(self, func): return self.decorate(func) @staticmethod def make_decorator(*shapes, **kwargs): """ Constructs the decorator that validates inputs. This method is NOT author-facing; its inputs undergo no validation. Used internally in mitxgraders library. """ display_name = kwargs.get('display_name', None) min_length = kwargs.get('min_length', None) if min_length is not None: any_schema = Schema(has_shape(shapes[0])) else: len_schema = [Schema(has_shape(shape)) for shape in shapes] # can't use @wraps, func might be a numpy ufunc def decorator(func): func_name = display_name if display_name else func.__name__ @wraps(func) def _func(*args): # Set up the schemas and shapes for validation. # Also check the number of arguments provided is correct. # Use the same response as in validate_function_call in expressions.py msg = '' if min_length is not None: schemas = [any_schema] * len(args) use_shapes = [shapes[0]] * len(args) if len(args) < min_length: msg = ( "Wrong number of arguments passed to {func_name}(...): " "Expected at least {expected} inputs, but received {received}." .format(func_name=func_name, expected=min_length, received=len(args))) else: schemas = len_schema use_shapes = shapes if len(shapes) != len(args): msg = ( "Wrong number of arguments passed to {func_name}(...): " "Expected {expected} inputs, but received {received}." .format(func_name=func_name, expected=len(shapes), received=len(args))) if msg: raise ArgumentError(msg) errors = [] for schema, arg in zip(schemas, args): try: schema(arg) errors.append(None) except Invalid as error: errors.append(error) if all([error is None for error in errors]): return func(*args) lines = [ 'There was an error evaluating function {0}(...)'.format( func_name) ] for index, (shape, error) in enumerate(zip(use_shapes, errors)): ordinal = low_ordinal(index + 1) if error: lines.append( '{0} input has an error: '.format(ordinal) + error.error_message) else: expected = get_shape_description(shape) lines.append( '{0} input is ok: received a {1} as expected'. format(ordinal, expected)) message = "\n".join(lines) raise ArgumentShapeError(message) _func.validated = True return _func return decorator