Skip to content

Commit b6a2ca1

Browse files
committed
Wrap up implementation and add tests
1 parent e1ccf02 commit b6a2ca1

12 files changed

+930
-43
lines changed

python/pydantic_core/core_schema.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -3582,7 +3582,7 @@ def arguments_v3_schema(
35823582
)
35833583
schema = core_schema.arguments_v3_schema([param_a, param_b])
35843584
v = SchemaValidator(schema)
3585-
assert v.validate_python({'a': 'hello', 'kwargs': {'extra': True}}) == (('hello',), {'extra': True})
3585+
assert v.validate_python({'a': 'hi', 'kwargs': {'b': True}}) == (('hi',), {'b': True})
35863586
```
35873587
35883588
Args:
@@ -4089,6 +4089,7 @@ def definition_reference_schema(
40894089
'dataclass-args',
40904090
'dataclass',
40914091
'arguments',
4092+
'arguments-v3',
40924093
'call',
40934094
'custom-error',
40944095
'json',

src/validators/arguments_v3.rs

+51-30
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,6 @@ impl BuildValidator for ArgumentsV3Validator {
102102

103103
let mode = ParameterMode::from_str(py_mode)?;
104104

105-
// let positional = mode == "positional_only" || mode == "positional_or_keyword";
106-
// if positional {
107-
// positional_params_count = arg_index + 1;
108-
// }
109-
110105
if mode == ParameterMode::KeywordOnly {
111106
had_keyword_only = true;
112107
}
@@ -129,7 +124,7 @@ impl BuildValidator for ArgumentsV3Validator {
129124
};
130125

131126
if had_default_arg && !has_default && !had_keyword_only {
132-
return py_schema_err!("Non-default argument '{}' follows default argument", name);
127+
return py_schema_err!("Required parameter '{}' follows parameter with default", name);
133128
} else if has_default {
134129
had_default_arg = true;
135130
}
@@ -215,19 +210,29 @@ impl ArgumentsV3Validator {
215210
}
216211
ParameterMode::VarArgs => match dict_value.borrow_input().validate_tuple(false) {
217212
Ok(tuple) => {
213+
let mut i: i64 = 0;
218214
tuple.unpack(state).try_for_each(|v| {
219215
match parameter.validator.validate(py, v.unwrap().borrow_input(), state) {
220216
Ok(tuple_value) => {
221217
output_args.push(tuple_value);
218+
i += 1;
222219
Ok(())
223220
}
224221
Err(ValError::LineErrors(line_errors)) => {
225222
errors.extend(line_errors.into_iter().map(|err| {
226-
lookup_path.apply_error_loc(err, self.loc_by_alias, &parameter.name)
223+
lookup_path.apply_error_loc(
224+
err.with_outer_location(i),
225+
self.loc_by_alias,
226+
&parameter.name,
227+
)
227228
}));
229+
i += 1;
228230
Ok(())
229231
}
230-
Err(err) => Err(err),
232+
Err(err) => {
233+
i += 1;
234+
Err(err)
235+
}
231236
}
232237
})?;
233238
}
@@ -292,31 +297,35 @@ impl ArgumentsV3Validator {
292297
}
293298
},
294299
ParameterMode::VarKwargsUnpackedTypedDict => {
295-
let kwargs_dict = dict_value
296-
.borrow_input()
297-
.as_kwargs(py)
298-
.unwrap_or_else(|| PyDict::new(py));
299-
match parameter.validator.validate(py, kwargs_dict.as_any(), state) {
300+
match parameter.validator.validate(py, dict_value.borrow_input(), state) {
300301
Ok(value) => {
301302
output_kwargs.update(value.downcast_bound::<PyDict>(py).unwrap().as_mapping())?;
302303
}
303304
Err(ValError::LineErrors(line_errors)) => {
304-
errors.extend(line_errors);
305+
errors.extend(
306+
line_errors.into_iter().map(|err| {
307+
lookup_path.apply_error_loc(err, self.loc_by_alias, &parameter.name)
308+
}),
309+
);
305310
}
306311
Err(err) => return Err(err),
307312
}
308313
}
309314
}
310-
// No value is present in the mapping, fallback to the default value (and error if no default):
315+
// No value is present in the mapping...
311316
} else {
312317
match parameter.mode {
318+
// ... fallback to the default value (and error if no default):
313319
ParameterMode::PositionalOnly | ParameterMode::PositionalOrKeyword | ParameterMode::KeywordOnly => {
314320
if let Some(value) =
315321
parameter
316322
.validator
317323
.default_value(py, Some(parameter.name.as_str()), state)?
318324
{
319-
if parameter.mode == ParameterMode::PositionalOnly {
325+
if matches!(
326+
parameter.mode,
327+
ParameterMode::PositionalOnly | ParameterMode::PositionalOrKeyword
328+
) {
320329
output_args.push(value);
321330
} else {
322331
output_kwargs.set_item(PyString::new(py, parameter.name.as_str()).unbind(), value)?;
@@ -337,7 +346,23 @@ impl ArgumentsV3Validator {
337346
));
338347
}
339348
}
340-
// Variadic args/kwargs can be empty by definition:
349+
// ... validate the unpacked kwargs against an empty dict:
350+
ParameterMode::VarKwargsUnpackedTypedDict => {
351+
match parameter.validator.validate(py, PyDict::new(py).borrow_input(), state) {
352+
Ok(value) => {
353+
output_kwargs.update(value.downcast_bound::<PyDict>(py).unwrap().as_mapping())?;
354+
}
355+
Err(ValError::LineErrors(line_errors)) => {
356+
errors.extend(
357+
line_errors
358+
.into_iter()
359+
.map(|err| err.with_outer_location(&parameter.name)),
360+
);
361+
}
362+
Err(err) => return Err(err),
363+
}
364+
}
365+
// Variadic args/uniform kwargs can be empty by definition:
341366
_ => (),
342367
}
343368
}
@@ -436,13 +461,10 @@ impl ArgumentsV3Validator {
436461
.validator
437462
.default_value(py, Some(parameter.name.as_str()), state)?
438463
{
439-
if matches!(
440-
parameter.mode,
441-
ParameterMode::PositionalOnly | ParameterMode::PositionalOrKeyword
442-
) {
443-
output_kwargs.set_item(PyString::new(py, parameter.name.as_str()).unbind(), value)?;
444-
} else {
464+
if parameter.mode == ParameterMode::PositionalOnly {
445465
output_args.push(value);
466+
} else {
467+
output_kwargs.set_item(PyString::new(py, parameter.name.as_str()).unbind(), value)?;
446468
}
447469
} else {
448470
// Required and no default, error:
@@ -577,13 +599,12 @@ impl ArgumentsV3Validator {
577599
}
578600
}
579601

580-
if !remaining_kwargs.is_empty() {
581-
// In this case, the unpacked typeddict var kwargs parameter is guaranteed to exist:
582-
let var_kwargs_parameter = self
583-
.parameters
584-
.iter()
585-
.find(|p| p.mode == ParameterMode::VarKwargsUnpackedTypedDict)
586-
.unwrap();
602+
let maybe_var_kwargs_parameter = self
603+
.parameters
604+
.iter()
605+
.find(|p| p.mode == ParameterMode::VarKwargsUnpackedTypedDict);
606+
607+
if let Some(var_kwargs_parameter) = maybe_var_kwargs_parameter {
587608
match var_kwargs_parameter
588609
.validator
589610
.validate(py, remaining_kwargs.as_any(), state)

tests/test_schema_functions.py

+18
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,24 @@ def args(*args, **kwargs):
225225
'serialization': {'type': 'format', 'formatting_string': 'd'},
226226
},
227227
),
228+
(
229+
core_schema.arguments_v3_schema,
230+
args(
231+
[
232+
core_schema.arguments_v3_parameter('foo', core_schema.int_schema()),
233+
core_schema.arguments_v3_parameter('bar', core_schema.str_schema()),
234+
],
235+
serialization=core_schema.format_ser_schema('d'),
236+
),
237+
{
238+
'type': 'arguments-v3',
239+
'arguments_schema': [
240+
{'name': 'foo', 'schema': {'type': 'int'}},
241+
{'name': 'bar', 'schema': {'type': 'str'}},
242+
],
243+
'serialization': {'type': 'format', 'formatting_string': 'd'},
244+
},
245+
),
228246
(
229247
core_schema.call_schema,
230248
args(core_schema.arguments_schema([core_schema.arguments_parameter('foo', {'type': 'int'})]), val_function),

tests/validators/arguments_v3/__init__.py

Whitespace-only changes.
+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from __future__ import annotations
2+
3+
import re
4+
5+
import pytest
6+
7+
from pydantic_core import ArgsKwargs, SchemaValidator, ValidationError
8+
from pydantic_core import core_schema as cs
9+
10+
from ...conftest import Err, PyAndJson
11+
12+
13+
@pytest.mark.parametrize(
14+
['input_value', 'expected'],
15+
(
16+
[ArgsKwargs((1,)), ((1,), {})],
17+
[ArgsKwargs((), {'Foo': 1}), ((), {'a': 1})],
18+
[ArgsKwargs((), {'a': 1}), Err('Foo\n Missing required argument [type=missing_argument,')],
19+
[{'Foo': 1}, ((1,), {})],
20+
[{'a': 1}, Err('Foo\n Missing required argument [type=missing_argument,')],
21+
),
22+
ids=repr,
23+
)
24+
def test_alias(py_and_json: PyAndJson, input_value, expected) -> None:
25+
v = py_and_json(
26+
cs.arguments_v3_schema(
27+
[
28+
cs.arguments_v3_parameter(name='a', schema=cs.int_schema(), alias='Foo', mode='positional_or_keyword'),
29+
]
30+
)
31+
)
32+
if isinstance(expected, Err):
33+
with pytest.raises(ValidationError, match=re.escape(expected.message)):
34+
v.validate_test(input_value)
35+
else:
36+
assert v.validate_test(input_value) == expected
37+
38+
39+
@pytest.mark.parametrize(
40+
['input_value', 'expected'],
41+
(
42+
[ArgsKwargs((1,)), ((1,), {})],
43+
[ArgsKwargs((), {'Foo': 1}), ((), {'a': 1})],
44+
[ArgsKwargs((), {'a': 1}), ((), {'a': 1})],
45+
[ArgsKwargs((), {'a': 1, 'b': 2}), Err('b\n Unexpected keyword argument [type=unexpected_keyword_argument,')],
46+
[
47+
ArgsKwargs((), {'a': 1, 'Foo': 2}),
48+
Err('a\n Unexpected keyword argument [type=unexpected_keyword_argument,'),
49+
],
50+
[{'Foo': 1}, ((1,), {})],
51+
[{'a': 1}, ((1,), {})],
52+
),
53+
ids=repr,
54+
)
55+
def test_alias_validate_by_name(py_and_json: PyAndJson, input_value, expected):
56+
v = py_and_json(
57+
cs.arguments_v3_schema(
58+
[
59+
cs.arguments_v3_parameter(name='a', schema=cs.int_schema(), alias='Foo', mode='positional_or_keyword'),
60+
],
61+
validate_by_name=True,
62+
)
63+
)
64+
if isinstance(expected, Err):
65+
with pytest.raises(ValidationError, match=re.escape(expected.message)):
66+
v.validate_test(input_value)
67+
else:
68+
assert v.validate_test(input_value) == expected
69+
70+
71+
def test_only_validate_by_name(py_and_json) -> None:
72+
v = py_and_json(
73+
cs.arguments_v3_schema(
74+
[
75+
cs.arguments_v3_parameter(
76+
name='a', schema=cs.str_schema(), alias='FieldA', mode='positional_or_keyword'
77+
),
78+
],
79+
validate_by_name=True,
80+
validate_by_alias=False,
81+
)
82+
)
83+
84+
assert v.validate_test(ArgsKwargs((), {'a': 'hello'})) == ((), {'a': 'hello'})
85+
assert v.validate_test({'a': 'hello'}) == (('hello',), {})
86+
87+
with pytest.raises(ValidationError, match=r'a\n +Missing required argument \[type=missing_argument,'):
88+
assert v.validate_test(ArgsKwargs((), {'FieldA': 'hello'}))
89+
with pytest.raises(ValidationError, match=r'a\n +Missing required argument \[type=missing_argument,'):
90+
assert v.validate_test({'FieldA': 'hello'})
91+
92+
93+
def test_only_allow_alias(py_and_json) -> None:
94+
v = py_and_json(
95+
cs.arguments_v3_schema(
96+
[
97+
cs.arguments_v3_parameter(
98+
name='a', schema=cs.str_schema(), alias='FieldA', mode='positional_or_keyword'
99+
),
100+
],
101+
validate_by_name=False,
102+
validate_by_alias=True,
103+
)
104+
)
105+
assert v.validate_test(ArgsKwargs((), {'FieldA': 'hello'})) == ((), {'a': 'hello'})
106+
assert v.validate_test({'FieldA': 'hello'}) == (('hello',), {})
107+
108+
with pytest.raises(ValidationError, match=r'FieldA\n +Missing required argument \[type=missing_argument,'):
109+
assert v.validate_test(ArgsKwargs((), {'a': 'hello'}))
110+
with pytest.raises(ValidationError, match=r'FieldA\n +Missing required argument \[type=missing_argument,'):
111+
assert v.validate_test({'a': 'hello'})
112+
113+
114+
@pytest.mark.parametrize('config_by_alias', [None, True, False])
115+
@pytest.mark.parametrize('config_by_name', [None, True, False])
116+
@pytest.mark.parametrize('runtime_by_alias', [None, True, False])
117+
@pytest.mark.parametrize('runtime_by_name', [None, True, False])
118+
def test_by_alias_and_name_config_interaction(
119+
config_by_alias: bool | None,
120+
config_by_name: bool | None,
121+
runtime_by_alias: bool | None,
122+
runtime_by_name: bool | None,
123+
) -> None:
124+
"""This test reflects the priority that applies for config vs runtime validation alias configuration.
125+
126+
Runtime values take precedence over config values, when set.
127+
By default, by_alias is True and by_name is False.
128+
"""
129+
130+
if config_by_alias is False and config_by_name is False and runtime_by_alias is False and runtime_by_name is False:
131+
pytest.skip("Can't have both by_alias and by_name as effectively False")
132+
133+
schema = cs.arguments_v3_schema(
134+
arguments=[
135+
cs.arguments_v3_parameter(name='my_field', schema=cs.int_schema(), alias='my_alias'),
136+
],
137+
**({'validate_by_alias': config_by_alias} if config_by_alias is not None else {}),
138+
**({'validate_by_name': config_by_name} if config_by_name is not None else {}),
139+
)
140+
s = SchemaValidator(schema)
141+
142+
alias_allowed = next(x for x in (runtime_by_alias, config_by_alias, True) if x is not None)
143+
name_allowed = next(x for x in (runtime_by_name, config_by_name, False) if x is not None)
144+
145+
if alias_allowed:
146+
assert s.validate_python(
147+
ArgsKwargs((), {'my_alias': 1}), by_alias=runtime_by_alias, by_name=runtime_by_name
148+
) == (
149+
(),
150+
{'my_field': 1},
151+
)
152+
if name_allowed:
153+
assert s.validate_python(
154+
ArgsKwargs((), {'my_field': 1}), by_alias=runtime_by_alias, by_name=runtime_by_name
155+
) == (
156+
(),
157+
{'my_field': 1},
158+
)

0 commit comments

Comments
 (0)