Skip to content

Commit 2fc8207

Browse files
dharani7998sloria
andauthored
@validates accepts multiple field names (#1965)
* multiple fields in validates decorator * authors.rst * Add test and update docs * Pass data_key * Update docs --------- Co-authored-by: Steven Loria <[email protected]>
1 parent b7026f3 commit 2fc8207

File tree

9 files changed

+131
-47
lines changed

9 files changed

+131
-47
lines changed

AUTHORS.rst

+1
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,5 @@ Contributors (chronological)
176176
- Peter C `@somethingnew2-0 <https://github.com/somethingnew2-0>`_
177177
- Marcel Jackwerth `@mrcljx` <https://github.com/mrcljx>`_
178178
- Fares Abubaker `@Fares-Abubaker <https://github.com/Fares-Abubaker>`_
179+
- Dharanikumar Sekar `@dharani7998 <https://github.com/dharani7998>`_
179180
- Nicolas Simonds `@0xDEC0DE <https://github.com/0xDEC0DE>`_

CHANGELOG.rst

+3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ Features:
1414
`TimeDelta <marshmallow.fields.TimeDelta>`, and `Enum <marshmallow.fields.Enum>`
1515
accept their internal value types as valid input (:issue:`1415`).
1616
Thanks :user:`bitdancer` for the suggestion.
17+
- `@validates <marshmallow.validates>` accepts multiple field names (:issue:`1960`).
18+
*Backwards-incompatible*: Decorated methods now receive ``data_key`` as a keyword argument.
19+
Thanks :user:`dpriskorn` for the suggestion and :user:`dharani7998` for the PR.
1720

1821
Other changes:
1922

docs/quickstart.rst

+20-2
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ You may also pass a collection (list, tuple, generator) of callables to ``valida
290290
Field validators as methods
291291
+++++++++++++++++++++++++++
292292

293-
It is sometimes convenient to write validators as methods. Use the `validates <marshmallow.decorators.validates>` decorator to register field validator methods.
293+
It is sometimes convenient to write validators as methods. Use the `validates <marshmallow.validates>` decorator to register field validator methods.
294294

295295
.. code-block:: python
296296
@@ -301,12 +301,30 @@ It is sometimes convenient to write validators as methods. Use the `validates <m
301301
quantity = fields.Integer()
302302
303303
@validates("quantity")
304-
def validate_quantity(self, value):
304+
def validate_quantity(self, value: int, data_key: str) -> None:
305305
if value < 0:
306306
raise ValidationError("Quantity must be greater than 0.")
307307
if value > 30:
308308
raise ValidationError("Quantity must not be greater than 30.")
309309
310+
.. note::
311+
312+
You can pass multiple field names to the `validates <marshmallow.validates>` decorator.
313+
314+
.. code-block:: python
315+
316+
from marshmallow import Schema, fields, validates, ValidationError
317+
318+
319+
class UserSchema(Schema):
320+
name = fields.Str(required=True)
321+
nickname = fields.Str(required=True)
322+
323+
@validates("name", "nickname")
324+
def validate_names(self, value: str, data_key: str) -> None:
325+
if len(value) < 3:
326+
raise ValidationError("Too short")
327+
310328
311329
Required fields
312330
---------------

docs/upgrading.rst

+38
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,44 @@ To automatically generate schema fields from model classes, consider using a sep
137137
name = auto_field()
138138
birthdate = auto_field()
139139
140+
`@validates <marshmallow.validates>` accepts multiple field names
141+
*****************************************************************
142+
143+
The `@validates <marshmallow.validates>` decorator now accepts multiple field names as arguments.
144+
Decorated methods receive ``data_key`` as a keyword argument.
145+
146+
.. code-block:: python
147+
148+
from marshmallow import fields, Schema, validates
149+
150+
151+
# 3.x
152+
class UserSchema(Schema):
153+
name = fields.Str(required=True)
154+
nickname = fields.Str(required=True)
155+
156+
@validates("name")
157+
def validate_name(self, value: str) -> None:
158+
if len(value) < 3:
159+
raise ValidationError('"name" too short')
160+
161+
@validates("nickname")
162+
def validate_nickname(self, value: str) -> None:
163+
if len(value) < 3:
164+
raise ValidationError('"nickname" too short')
165+
166+
167+
# 4.x
168+
class UserSchema(Schema):
169+
name = fields.Str(required=True)
170+
nickname = fields.Str(required=True)
171+
172+
@validates("name", "nickname")
173+
def validate_names(self, value: str, data_key: str) -> None:
174+
if len(value) < 3:
175+
raise ValidationError(f'"{data_key}" too short')
176+
177+
140178
Remove ``ordered`` from the `SchemaOpts <marshmallow.SchemaOpts>` constructor
141179
*****************************************************************************
142180

src/marshmallow/decorators.py

+7-4
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,15 @@ class MarshmallowHook:
8383
__marshmallow_hook__: dict[str, list[tuple[bool, Any]]] | None = None
8484

8585

86-
def validates(field_name: str) -> Callable[..., Any]:
87-
"""Register a field validator.
86+
def validates(*field_names: str) -> Callable[..., Any]:
87+
"""Register a validator method for field(s).
8888
89-
:param field_name: Name of the field that the method validates.
89+
:param field_names: Names of the fields that the method validates.
90+
91+
.. versionchanged:: 4.0.0 Accepts multiple field names as positional arguments.
92+
.. versionchanged:: 4.0.0 Decorated methods receive ``data_key`` as a keyword argument.
9093
"""
91-
return set_hook(None, VALIDATES, field_name=field_name)
94+
return set_hook(None, VALIDATES, field_names=field_names)
9295

9396

9497
def validates_schema(

src/marshmallow/schema.py

+34-30
Original file line numberDiff line numberDiff line change
@@ -1120,48 +1120,52 @@ def _invoke_load_processors(
11201120
def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool):
11211121
for attr_name, _, validator_kwargs in self._hooks[VALIDATES]:
11221122
validator = getattr(self, attr_name)
1123-
field_name = validator_kwargs["field_name"]
11241123

1125-
try:
1126-
field_obj = self.fields[field_name]
1127-
except KeyError as error:
1128-
if field_name in self.declared_fields:
1129-
continue
1130-
raise ValueError(f'"{field_name}" field does not exist.') from error
1124+
field_names = validator_kwargs["field_names"]
11311125

1132-
data_key = (
1133-
field_obj.data_key if field_obj.data_key is not None else field_name
1134-
)
1135-
if many:
1136-
for idx, item in enumerate(data):
1126+
for field_name in field_names:
1127+
try:
1128+
field_obj = self.fields[field_name]
1129+
except KeyError as error:
1130+
if field_name in self.declared_fields:
1131+
continue
1132+
raise ValueError(f'"{field_name}" field does not exist.') from error
1133+
1134+
data_key = (
1135+
field_obj.data_key if field_obj.data_key is not None else field_name
1136+
)
1137+
do_validate = functools.partial(validator, data_key=data_key)
1138+
1139+
if many:
1140+
for idx, item in enumerate(data):
1141+
try:
1142+
value = item[field_obj.attribute or field_name]
1143+
except KeyError:
1144+
pass
1145+
else:
1146+
validated_value = self._call_and_store(
1147+
getter_func=do_validate,
1148+
data=value,
1149+
field_name=data_key,
1150+
error_store=error_store,
1151+
index=(idx if self.opts.index_errors else None),
1152+
)
1153+
if validated_value is missing:
1154+
item.pop(field_name, None)
1155+
else:
11371156
try:
1138-
value = item[field_obj.attribute or field_name]
1157+
value = data[field_obj.attribute or field_name]
11391158
except KeyError:
11401159
pass
11411160
else:
11421161
validated_value = self._call_and_store(
1143-
getter_func=validator,
1162+
getter_func=do_validate,
11441163
data=value,
11451164
field_name=data_key,
11461165
error_store=error_store,
1147-
index=(idx if self.opts.index_errors else None),
11481166
)
11491167
if validated_value is missing:
1150-
item.pop(field_name, None)
1151-
else:
1152-
try:
1153-
value = data[field_obj.attribute or field_name]
1154-
except KeyError:
1155-
pass
1156-
else:
1157-
validated_value = self._call_and_store(
1158-
getter_func=validator,
1159-
data=value,
1160-
field_name=data_key,
1161-
error_store=error_store,
1162-
)
1163-
if validated_value is missing:
1164-
data.pop(field_name, None)
1168+
data.pop(field_name, None)
11651169

11661170
def _invoke_schema_validators(
11671171
self,

tests/test_context.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ class InnerSchema(Schema):
111111
foo = fields.Raw()
112112

113113
@validates("foo")
114-
def validate_foo(self, value):
114+
def validate_foo(self, value, **kwargs):
115115
if "foo_context" not in Context[dict].get():
116116
raise ValidationError("Missing context")
117117

@@ -132,7 +132,7 @@ class InnerSchema(Schema):
132132
foo = fields.Raw()
133133

134134
@validates("foo")
135-
def validate_foo(self, value):
135+
def validate_foo(self, value, **kwargs):
136136
if "foo_context" not in Context[dict].get():
137137
raise ValidationError("Missing context")
138138

tests/test_decorators.py

+23-6
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ class ValidatesSchema(Schema):
251251
foo = fields.Int()
252252

253253
@validates("foo")
254-
def validate_foo(self, value):
254+
def validate_foo(self, value, **kwargs):
255255
if value != 42:
256256
raise ValidationError("The answer to life the universe and everything.")
257257

@@ -262,7 +262,7 @@ class VSchema(Schema):
262262
s = fields.String()
263263

264264
@validates("s")
265-
def validate_string(self, data):
265+
def validate_string(self, data, **kwargs):
266266
raise ValidationError("nope")
267267

268268
with pytest.raises(ValidationError) as excinfo:
@@ -276,7 +276,7 @@ class S1(Schema):
276276
s = fields.String(attribute="string_name")
277277

278278
@validates("s")
279-
def validate_string(self, data):
279+
def validate_string(self, data, **kwargs):
280280
raise ValidationError("nope")
281281

282282
with pytest.raises(ValidationError) as excinfo:
@@ -330,7 +330,7 @@ def test_validates_decorator(self):
330330
def test_field_not_present(self):
331331
class BadSchema(ValidatesSchema):
332332
@validates("bar")
333-
def validate_bar(self, value):
333+
def validate_bar(self, value, **kwargs):
334334
raise ValidationError("Never raised.")
335335

336336
schema = BadSchema()
@@ -344,7 +344,7 @@ class Schema2(ValidatesSchema):
344344
bar = fields.Int(validate=validate.Equal(1))
345345

346346
@validates("bar")
347-
def validate_bar(self, value):
347+
def validate_bar(self, value, **kwargs):
348348
if value != 2:
349349
raise ValidationError("Must be 2")
350350

@@ -371,7 +371,7 @@ class BadSchema(Schema):
371371
foo = fields.String(data_key="foo-name")
372372

373373
@validates("foo")
374-
def validate_string(self, data):
374+
def validate_string(self, data, **kwargs):
375375
raise ValidationError("nope")
376376

377377
schema = BadSchema()
@@ -385,6 +385,23 @@ def validate_string(self, data):
385385
)
386386
assert errors == {0: {"foo-name": ["nope"]}, 1: {"foo-name": ["nope"]}}
387387

388+
def test_validates_accepts_multiple_fields(self):
389+
class BadSchema(Schema):
390+
foo = fields.String()
391+
bar = fields.String(data_key="Bar")
392+
393+
@validates("foo", "bar")
394+
def validate_string(self, data: str, data_key: str):
395+
raise ValidationError(f"'{data}' is invalid for {data_key}.")
396+
397+
schema = BadSchema()
398+
with pytest.raises(ValidationError) as excinfo:
399+
schema.load({"foo": "data", "Bar": "data2"})
400+
assert excinfo.value.messages == {
401+
"foo": ["'data' is invalid for foo."],
402+
"Bar": ["'data2' is invalid for Bar."],
403+
}
404+
388405

389406
class TestValidatesSchemaDecorator:
390407
def test_validator_nested_many_invalid_data(self):

tests/test_schema.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -1741,11 +1741,11 @@ class MySchema(Schema):
17411741
b = fields.Raw()
17421742

17431743
@validates("a")
1744-
def validate_a(self, val):
1744+
def validate_a(self, val, **kwargs):
17451745
raise ValidationError({"code": "invalid_a"})
17461746

17471747
@validates("b")
1748-
def validate_b(self, val):
1748+
def validate_b(self, val, **kwargs):
17491749
raise ValidationError({"code": "invalid_b"})
17501750

17511751
s = MySchema(only=("b",))
@@ -1935,7 +1935,7 @@ class Outer(Schema):
19351935
inner = fields.Nested(Inner, many=True)
19361936

19371937
@validates("inner")
1938-
def validates_inner(self, data):
1938+
def validates_inner(self, data, **kwargs):
19391939
raise ValidationError("not a chance")
19401940

19411941
outer = Outer()

0 commit comments

Comments
 (0)