marshmallow
marshmallow copied to clipboard
Pass schema's context to validation functions
I am thinking about a way to pass context to validation function.
Currently validation function expect exactly one argument, value of a field. Maybe Field._validate method can check with inspect.getargspec and if function expect two arguments, send schema's context as second argument?
I know that context is available in validation methods declared with @validates decorator, but I want to reuse validation function in different schemas with different fields.
An interesting idea. I don't think using inspect is the right approach; we used to do this with Function and Method fields, but it turned out to incur significant performance overhead. I think it would be more consistent with the current API to use a decorator, e.g. @pass_schema_context.
Something like this?
@pass_schema_context
def validate_quantity(n, context):
pass
class ItemSchema(Schema):
quantity = fields.Integer(validate=validate_quantity)
Yes, that was what I was thinking. Although it might be better to pass the field and field name rather than just the context. That would allow you to access the context with field.context.
@evgeny-sureev What is your intended use for the schema context?
Symantically, validation involving a schema instance would be considered schema level validation, which uses the @validates_schema decorator. The documentation shows an example of accessing a schema instance in a validator.
https://marshmallow.readthedocs.io/en/latest/extending.html#schema-level-validation
@deckar01 I have a use case where it would be helpful if context could be passed to validators.
For example:
class IPSchema(Schema):
cidr = fields.String()
@validates('cidr')
def valid_network(self, data):
if data not in self.context.get('whitelisted_networks', []):
raise ValidationError('Network {0} is not allowed'.format(data))
This way we could selectively pass in what is allowed, perhaps for a given user:
whitelist = ['192.168.1.1/32']
admin_whitelist = ['0.0.0.0/0']
schema = IPSchema()
if user.is_admin:
schema.context['network_whitelist'] = admin_whitelist
else:
schema.context['network_whitelist'] = whitelist
schema.load({'ip': '172.168.1.1/32'})
If there is concern about mixing serialization/de-serialization endpoint perhaps we define a validation_context instead?
@kevgliss You can use the existing @validate_schema decorator to validate against the schema context. The field_names parameter of the ValidationError exception allows you to specify which field(s) the error belongs to.
class IPSchema(Schema):
cidr = fields.String()
@validate_schema
def valid_network(self, data):
cidr = data['cidr']
if cidr not in self.context.get('whitelisted_networks', []):
raise ValidationError('Network {0} is not allowed'.format(cidr), ['cidr'])
@kevgliss I agree with @deckar01 that you can access context via self if you make validation a schema's member function. Although IMO this kind of validations should not be done during deserialization. Instead you check them afterwards on deserialized data. E.g. you do not check if passed "name" value is unique inside your database in data deserialization code.
Thanks @deckar01, I didn't see that fields could be specified on schema level validation.
@maximkulkin I would generally agree, although I am experimenting with abusing marshmallow by using it for validation outside of the normal serialization process.
https://github.com/kevgliss/aws-flare
@kevgliss Yes, this kind of checks is easier to implement outside of deserialization logic.
I want to reuse validation function in different schemas with different fields.
I didn't fully digest this requirement the first time around.
Currently you would have to:
def validate_foo(foo, field_name, context):
if foo not in context['bar']:
raise ValidationError('Foo must be in bar', [field_name])
class ItemSchema(Schema):
quantity = fields.Integer()
@validates_schema
def validate_quantity(self, data):
validate_foo(data['quantity'], 'quantity', self.context)
class AnotherSchema(Schema):
quantity = fields.Integer()
@validates_schema
def validate_quantity(self, data):
validate_foo(data['quantity'], 'quantity', self.context)
but it would be a lot more convenient if you had access to the context in a field validator:
@pass_schema_context
def validate_foo(foo, context):
if foo not in context['bar']:
raise ValidationError('Foo must be in bar')
class ItemSchema(Schema):
quantity = fields.Integer(validate=validate_foo)
class AnotherSchema(Schema):
quantity = fields.Integer(validate=validate_foo)
So Field._validate() would need to be able to efficiently identify when a validator has been wrapped in @pass_schema_context decorator and provide the schema context as an additional argument.
I'm not sure it makes sense to use a decorator outside of the schema body in order to identify that a field-level validator expects schema-level data. All the other decorators are used to register a processor with a specific schema class.
👍 for passing context to validators
+1 for passing context to validators. My use case is the following:
class ItemSchema(Schema):
item_id = fields.Integer(validate=lambda val, ctx: val in ctx.get('admissible_ids', ()))
schema = ItemSchema(context={'admissible_ids': WHITELISTED_IDS})
schema.load(input_dict)
There is a proposal in #334 to allow error messages to be defined as callables that receive the input value and field object. It would also make sense to also pass the field object to validators if we move forward with the OP.
Proposed fix in #709
DRF added this functionality--we may decide to do something similar? https://www.django-rest-framework.org/community/3.11-announcement/#validator-default-context
For anyone looking for a way to do this in 2021 and don't mind some extra overhead, here is a generic hack.
from inspect import signature
from marshmallow import Schema, fields
class ExtendedSchema(Schema):
def __init__(self, *args, validator_context=None, **kwargs):
super().__init__(*args, **kwargs)
self.validator_context = validator_context
def on_bind_field(self, field_name, field_obj):
field_obj.validators = [lambda val: self._validate_with_context(method, val) for method in field_obj.validators]
def _validate_with_context(self, method, value):
args = signature(method).parameters
if len(args) > 1 and 'context' in args.keys():
return method(value, context=self.validator_context)
else:
return method(value)
class MySchema(ExtendedSchema):
name = fields.String(validate=custom_validator)
def custom_validator(value, context):
# Use context here
pass
You can also set self.validator_context via @pre_load if you want to base it on the input data.