From eeab8fc4300f1bf172fccd4d75890b756442c2f5 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Thu, 20 Oct 2022 14:36:25 +0100 Subject: [PATCH 1/2] change how arguments are defined --- pydantic_core/_pydantic_core.pyi | 2 + pydantic_core/core_schema.py | 2 + src/errors/kinds.rs | 6 +- src/input/input_json.rs | 54 ++++-- src/input/input_python.rs | 70 +++++--- tests/benchmarks/test_micro_benchmarks.py | 7 +- tests/test_errors.py | 200 ++++++++++++---------- tests/test_misc.py | 4 +- tests/validators/test_arguments.py | 198 +++++++++++---------- tests/validators/test_call.py | 22 +-- tests/validators/test_with_default.py | 6 +- 11 files changed, 332 insertions(+), 239 deletions(-) diff --git a/pydantic_core/_pydantic_core.pyi b/pydantic_core/_pydantic_core.pyi index f3461e179..637c9d7f4 100644 --- a/pydantic_core/_pydantic_core.pyi +++ b/pydantic_core/_pydantic_core.pyi @@ -11,12 +11,14 @@ else: __all__ = ( '__version__', + 'build_profile', 'SchemaValidator', 'SchemaError', 'ValidationError', 'PydanticCustomError', 'PydanticKindError', 'PydanticOmit', + 'list_all_errors', ) __version__: str build_profile: str diff --git a/pydantic_core/core_schema.py b/pydantic_core/core_schema.py index 82ebb66c9..14f5eb2b8 100644 --- a/pydantic_core/core_schema.py +++ b/pydantic_core/core_schema.py @@ -1138,6 +1138,8 @@ def json_schema(schema: CoreSchema | None = None, *, ref: str | None = None, ext 'union_tag_invalid', 'union_tag_not_found', 'arguments_type', + 'positional_arguments_type', + 'keyword_arguments_type', 'unexpected_keyword_argument', 'missing_keyword_argument', 'unexpected_positional_argument', diff --git a/src/errors/kinds.rs b/src/errors/kinds.rs index 4ef16c6e5..a9bedd16b 100644 --- a/src/errors/kinds.rs +++ b/src/errors/kinds.rs @@ -301,8 +301,12 @@ pub enum ErrorKind { }, // --------------------- // argument errors - #[strum(message = "Arguments must be a tuple of (positional arguments, keyword arguments) or a plain dict")] + #[strum(message = "Arguments must be a tuple, list or a dictionary")] ArgumentsType, + #[strum(message = "Positional arguments must be a list or tuple")] + PositionalArgumentsType, + #[strum(message = "Keyword arguments must be a dictionary")] + KeywordArgumentsType, #[strum(message = "Unexpected keyword argument")] UnexpectedKeywordArgument, #[strum(message = "Missing required keyword argument")] diff --git a/src/input/input_json.rs b/src/input/input_json.rs index 02999b939..8b09d18a0 100644 --- a/src/input/input_json.rs +++ b/src/input/input_json.rs @@ -1,6 +1,6 @@ use pyo3::prelude::*; -use crate::errors::{ErrorKind, InputValue, LocItem, ValError, ValResult}; +use crate::errors::{ErrorKind, InputValue, LocItem, ValError, ValLineError, ValResult}; use super::datetime::{ bytes_as_date, bytes_as_datetime, bytes_as_time, bytes_as_timedelta, float_as_datetime, float_as_duration, @@ -56,24 +56,44 @@ impl<'a> Input<'a> for JsonInput { fn validate_args(&'a self) -> ValResult<'a, GenericArguments<'a>> { match self { - JsonInput::Object(kwargs) => Ok(JsonArgs::new(None, Some(kwargs)).into()), - JsonInput::Array(array) => { - if array.len() != 2 { - Err(ValError::new(ErrorKind::ArgumentsType, self)) - } else { - let args = match unsafe { array.get_unchecked(0) } { - JsonInput::Null => None, - JsonInput::Array(args) => Some(args.as_slice()), - _ => return Err(ValError::new(ErrorKind::ArgumentsType, self)), - }; - let kwargs = match unsafe { array.get_unchecked(1) } { - JsonInput::Null => None, - JsonInput::Object(kwargs) => Some(kwargs), - _ => return Err(ValError::new(ErrorKind::ArgumentsType, self)), - }; - Ok(JsonArgs::new(args, kwargs).into()) + JsonInput::Object(object) => { + if let Some(args) = object.get("__args__") { + if let Some(kwargs) = object.get("__kwargs__") { + // we only try this logic if there are only these two items in the dict + if object.len() == 2 { + let args = match args { + JsonInput::Null => Ok(None), + JsonInput::Array(args) => Ok(Some(args.as_slice())), + _ => Err(ValLineError::new_with_loc( + ErrorKind::PositionalArgumentsType, + args, + "__args__", + )), + }; + let kwargs = match kwargs { + JsonInput::Null => Ok(None), + JsonInput::Object(kwargs) => Ok(Some(kwargs)), + _ => Err(ValLineError::new_with_loc( + ErrorKind::KeywordArgumentsType, + kwargs, + "__kwargs__", + )), + }; + + return match (args, kwargs) { + (Ok(args), Ok(kwargs)) => Ok(JsonArgs::new(args, kwargs).into()), + (Err(args_error), Err(kwargs_error)) => { + return Err(ValError::LineErrors(vec![args_error, kwargs_error])) + } + (Err(error), _) => Err(ValError::LineErrors(vec![error])), + (_, Err(error)) => Err(ValError::LineErrors(vec![error])), + }; + } + } } + Ok(JsonArgs::new(None, Some(object)).into()) } + JsonInput::Array(array) => Ok(JsonArgs::new(Some(array), None).into()), _ => Err(ValError::new(ErrorKind::ArgumentsType, self)), } } diff --git a/src/input/input_python.rs b/src/input/input_python.rs index cfe529552..a31226451 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -12,7 +12,7 @@ use pyo3::types::{ use pyo3::types::{PyDictItems, PyDictKeys, PyDictValues}; use pyo3::{ffi, intern, AsPyPointer, PyTypeInfo}; -use crate::errors::{py_err_string, ErrorKind, InputValue, LocItem, ValError, ValResult}; +use crate::errors::{py_err_string, ErrorKind, InputValue, LocItem, ValError, ValLineError, ValResult}; use super::datetime::{ bytes_as_date, bytes_as_datetime, bytes_as_time, bytes_as_timedelta, date_as_datetime, float_as_datetime, @@ -114,26 +114,54 @@ impl<'a> Input<'a> for PyAny { } fn validate_args(&'a self) -> ValResult<'a, GenericArguments<'a>> { - if let Ok(kwargs) = self.cast_as::() { - Ok(PyArgs::new(None, Some(kwargs)).into()) - } else if let Ok((args, kwargs)) = self.extract::<(&PyAny, &PyAny)>() { - let args = if let Ok(tuple) = args.cast_as::() { - Some(tuple) - } else if args.is_none() { - None - } else if let Ok(list) = args.cast_as::() { - Some(PyTuple::new(self.py(), list.iter().collect::>())) - } else { - return Err(ValError::new(ErrorKind::ArgumentsType, self)); - }; - let kwargs = if let Ok(dict) = kwargs.cast_as::() { - Some(dict) - } else if kwargs.is_none() { - None - } else { - return Err(ValError::new(ErrorKind::ArgumentsType, self)); - }; - Ok(PyArgs::new(args, kwargs).into()) + if let Ok(dict) = self.cast_as::() { + if let Some(args) = dict.get_item("__args__") { + if let Some(kwargs) = dict.get_item("__kwargs__") { + // we only try this logic if there are only these two items in the dict + if dict.len() == 2 { + let args = if let Ok(tuple) = args.cast_as::() { + Ok(Some(tuple)) + } else if args.is_none() { + Ok(None) + } else if let Ok(list) = args.cast_as::() { + Ok(Some(PyTuple::new(self.py(), list.iter().collect::>()))) + } else { + Err(ValLineError::new_with_loc( + ErrorKind::PositionalArgumentsType, + args, + "__args__", + )) + }; + + let kwargs = if let Ok(dict) = kwargs.cast_as::() { + Ok(Some(dict)) + } else if kwargs.is_none() { + Ok(None) + } else { + Err(ValLineError::new_with_loc( + ErrorKind::KeywordArgumentsType, + kwargs, + "__kwargs__", + )) + }; + + return match (args, kwargs) { + (Ok(args), Ok(kwargs)) => Ok(PyArgs::new(args, kwargs).into()), + (Err(args_error), Err(kwargs_error)) => { + Err(ValError::LineErrors(vec![args_error, kwargs_error])) + } + (Err(error), _) => Err(ValError::LineErrors(vec![error])), + (_, Err(error)) => Err(ValError::LineErrors(vec![error])), + }; + } + } + } + Ok(PyArgs::new(None, Some(dict)).into()) + } else if let Ok(tuple) = self.cast_as::() { + Ok(PyArgs::new(Some(tuple), None).into()) + } else if let Ok(list) = self.cast_as::() { + let tuple = PyTuple::new(self.py(), list.iter().collect::>()); + Ok(PyArgs::new(Some(tuple), None).into()) } else { Err(ValError::new(ErrorKind::ArgumentsType, self)) } diff --git a/tests/benchmarks/test_micro_benchmarks.py b/tests/benchmarks/test_micro_benchmarks.py index 6dedc6f0f..4988dea69 100644 --- a/tests/benchmarks/test_micro_benchmarks.py +++ b/tests/benchmarks/test_micro_benchmarks.py @@ -889,9 +889,12 @@ def test_arguments(benchmark): ], } ) - assert v.validate_python(((1, 'a', 'true'), {'b': 'bb', 'c': 3})) == ((1, 'a', True), {'b': 'bb', 'c': 3}) + assert v.validate_python({'__args__': (1, 'a', 'true'), '__kwargs__': {'b': 'bb', 'c': 3}}) == ( + (1, 'a', True), + {'b': 'bb', 'c': 3}, + ) - benchmark(v.validate_python, ((1, 'a', 'true'), {'b': 'bb', 'c': 3})) + benchmark(v.validate_python, {'__args__': (1, 'a', 'true'), '__kwargs__': {'b': 'bb', 'c': 3}}) @pytest.mark.benchmark(group='defaults') diff --git a/tests/test_errors.py b/tests/test_errors.py index 904fd3dcb..8aea8a222 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -3,6 +3,7 @@ import pytest from pydantic_core import PydanticCustomError, PydanticKindError, PydanticOmit, SchemaValidator, ValidationError +from pydantic_core._pydantic_core import list_all_errors from .conftest import PyAndJson @@ -165,100 +166,105 @@ def f(input_value, **kwargs): ] -@pytest.mark.parametrize( - 'kind, message, context', - [ - ('json_invalid', 'Invalid JSON: foobar', {'error': 'foobar'}), - ('recursion_loop', 'Recursion error - cyclic reference detected', None), - ('dict_attributes_type', 'Input should be a valid dictionary or instance to extract fields from', None), - ('missing', 'Field required', None), - ('frozen', 'Field is frozen', None), - ('extra_forbidden', 'Extra inputs are not permitted', None), - ('invalid_key', 'Keys should be strings', None), - ('get_attribute_error', 'Error extracting attribute: foo', {'error': 'foo'}), - ('model_class_type', 'Input should be an instance of foo', {'class_name': 'foo'}), - ('none_required', 'Input should be None/null', None), - ('bool', 'Input should be a valid boolean', None), - ('greater_than', 'Input should be greater than 42.1', {'gt': 42.1}), - ('greater_than', 'Input should be greater than 42.1', {'gt': '42.1'}), - ('greater_than', 'Input should be greater than 2020-01-01', {'gt': '2020-01-01'}), - ('greater_than_equal', 'Input should be greater than or equal to 42.1', {'ge': 42.1}), - ('less_than', 'Input should be less than 42.1', {'lt': 42.1}), - ('less_than_equal', 'Input should be less than or equal to 42.1', {'le': 42.1}), - ('finite_number', 'Input should be a finite number', None), - ( - 'too_short', - 'Foobar should have at least 42 items after validation, not 40', - {'field_type': 'Foobar', 'min_length': 42, 'actual_length': 40}, - ), - ( - 'too_long', - 'Foobar should have at most 42 items after validation, not 50', - {'field_type': 'Foobar', 'max_length': 42, 'actual_length': 50}, - ), - ('string_type', 'Input should be a valid string', None), - ('string_unicode', 'Input should be a valid string, unable to parse raw data as a unicode string', None), - ('string_pattern_mismatch', "String should match pattern 'foo'", {'pattern': 'foo'}), - ('string_too_short', 'String should have at least 42 characters', {'min_length': 42}), - ('string_too_long', 'String should have at most 42 characters', {'max_length': 42}), - ('dict_type', 'Input should be a valid dictionary', None), - ('dict_from_mapping', 'Unable to convert mapping to a dictionary, error: foobar', {'error': 'foobar'}), - ('iteration_error', 'Error iterating over object, error: foobar', {'error': 'foobar'}), - ('list_type', 'Input should be a valid list/array', None), - ('tuple_type', 'Input should be a valid tuple', None), - ('set_type', 'Input should be a valid set', None), - ('bool_type', 'Input should be a valid boolean', None), - ('bool_parsing', 'Input should be a valid boolean, unable to interpret input', None), - ('int_type', 'Input should be a valid integer', None), - ('int_parsing', 'Input should be a valid integer, unable to parse string as an integer', None), - ('int_from_float', 'Input should be a valid integer, got a number with a fractional part', None), - ('multiple_of', 'Input should be a multiple of 42.1', {'multiple_of': 42.1}), - ('greater_than', 'Input should be greater than 42.1', {'gt': 42.1}), - ('greater_than_equal', 'Input should be greater than or equal to 42.1', {'ge': 42.1}), - ('less_than', 'Input should be less than 42.1', {'lt': 42.1}), - ('less_than_equal', 'Input should be less than or equal to 42.1', {'le': 42.1}), - ('float_type', 'Input should be a valid number', None), - ('float_parsing', 'Input should be a valid number, unable to parse string as an number', None), - ('bytes_type', 'Input should be a valid bytes', None), - ('bytes_too_short', 'Data should have at least 42 bytes', {'min_length': 42}), - ('bytes_too_long', 'Data should have at most 42 bytes', {'max_length': 42}), - ('value_error', 'Value error, foobar', {'error': 'foobar'}), - ('assertion_error', 'Assertion failed, foobar', {'error': 'foobar'}), - ('literal_error', 'Input should be foo', {'expected': 'foo'}), - ('literal_error', 'Input should be foo or bar', {'expected': 'foo or bar'}), - ('date_type', 'Input should be a valid date', None), - ('date_parsing', 'Input should be a valid date in the format YYYY-MM-DD, foobar', {'error': 'foobar'}), - ('date_from_datetime_parsing', 'Input should be a valid date or datetime, foobar', {'error': 'foobar'}), - ('date_from_datetime_inexact', 'Datetimes provided to dates should have zero time - e.g. be exact dates', None), - ('time_type', 'Input should be a valid time', None), - ('time_parsing', 'Input should be in a valid time format, foobar', {'error': 'foobar'}), - ('datetime_type', 'Input should be a valid datetime', None), - ('datetime_parsing', 'Input should be a valid datetime, foobar', {'error': 'foobar'}), - ('datetime_object_invalid', 'Invalid datetime object, got foobar', {'error': 'foobar'}), - ('time_delta_type', 'Input should be a valid timedelta', None), - ('time_delta_parsing', 'Input should be a valid timedelta, foobar', {'error': 'foobar'}), - ('frozen_set_type', 'Input should be a valid frozenset', None), - ('is_instance_of', 'Input should be an instance of Foo', {'class': 'Foo'}), - ('is_subclass_of', 'Input should be a subclass of Foo', {'class': 'Foo'}), - ('callable_type', 'Input should be callable', None), - ( - 'union_tag_invalid', - "Input tag 'foo' found using bar does not match any of the expected tags: baz", - {'discriminator': 'bar', 'tag': 'foo', 'expected_tags': 'baz'}, - ), - ('union_tag_not_found', 'Unable to extract tag using discriminator foo', {'discriminator': 'foo'}), - ( - 'arguments_type', - 'Arguments must be a tuple of (positional arguments, keyword arguments) or a plain dict', - None, - ), - ('unexpected_keyword_argument', 'Unexpected keyword argument', None), - ('missing_keyword_argument', 'Missing required keyword argument', None), - ('unexpected_positional_argument', 'Unexpected positional argument', None), - ('missing_positional_argument', 'Missing required positional argument', None), - ('multiple_argument_values', 'Got multiple values for argument', None), - ], -) +all_errors = [ + ('json_invalid', 'Invalid JSON: foobar', {'error': 'foobar'}), + ('json_type', 'JSON input should be str, bytes or bytearray', None), + ('recursion_loop', 'Recursion error - cyclic reference detected', None), + ('dict_attributes_type', 'Input should be a valid dictionary or instance to extract fields from', None), + ('missing', 'Field required', None), + ('frozen', 'Field is frozen', None), + ('extra_forbidden', 'Extra inputs are not permitted', None), + ('invalid_key', 'Keys should be strings', None), + ('get_attribute_error', 'Error extracting attribute: foo', {'error': 'foo'}), + ('model_class_type', 'Input should be an instance of foo', {'class_name': 'foo'}), + ('none_required', 'Input should be None/null', None), + ('bool', 'Input should be a valid boolean', None), + ('greater_than', 'Input should be greater than 42.1', {'gt': 42.1}), + ('greater_than', 'Input should be greater than 42.1', {'gt': '42.1'}), + ('greater_than', 'Input should be greater than 2020-01-01', {'gt': '2020-01-01'}), + ('greater_than_equal', 'Input should be greater than or equal to 42.1', {'ge': 42.1}), + ('less_than', 'Input should be less than 42.1', {'lt': 42.1}), + ('less_than_equal', 'Input should be less than or equal to 42.1', {'le': 42.1}), + ('finite_number', 'Input should be a finite number', None), + ( + 'too_short', + 'Foobar should have at least 42 items after validation, not 40', + {'field_type': 'Foobar', 'min_length': 42, 'actual_length': 40}, + ), + ( + 'too_long', + 'Foobar should have at most 42 items after validation, not 50', + {'field_type': 'Foobar', 'max_length': 42, 'actual_length': 50}, + ), + ('string_type', 'Input should be a valid string', None), + ('string_sub_type', 'Input should be a string, not an instance of a subclass of str', None), + ('string_unicode', 'Input should be a valid string, unable to parse raw data as a unicode string', None), + ('string_pattern_mismatch', "String should match pattern 'foo'", {'pattern': 'foo'}), + ('string_too_short', 'String should have at least 42 characters', {'min_length': 42}), + ('string_too_long', 'String should have at most 42 characters', {'max_length': 42}), + ('dict_type', 'Input should be a valid dictionary', None), + ('dict_from_mapping', 'Unable to convert mapping to a dictionary, error: foobar', {'error': 'foobar'}), + ('iterable_type', 'Input should be iterable', None), + ('iteration_error', 'Error iterating over object, error: foobar', {'error': 'foobar'}), + ('list_type', 'Input should be a valid list/array', None), + ('tuple_type', 'Input should be a valid tuple', None), + ('set_type', 'Input should be a valid set', None), + ('bool_type', 'Input should be a valid boolean', None), + ('bool_parsing', 'Input should be a valid boolean, unable to interpret input', None), + ('int_type', 'Input should be a valid integer', None), + ('int_parsing', 'Input should be a valid integer, unable to parse string as an integer', None), + ('int_from_float', 'Input should be a valid integer, got a number with a fractional part', None), + ('multiple_of', 'Input should be a multiple of 42.1', {'multiple_of': 42.1}), + ('greater_than', 'Input should be greater than 42.1', {'gt': 42.1}), + ('greater_than_equal', 'Input should be greater than or equal to 42.1', {'ge': 42.1}), + ('less_than', 'Input should be less than 42.1', {'lt': 42.1}), + ('less_than_equal', 'Input should be less than or equal to 42.1', {'le': 42.1}), + ('float_type', 'Input should be a valid number', None), + ('float_parsing', 'Input should be a valid number, unable to parse string as an number', None), + ('bytes_type', 'Input should be a valid bytes', None), + ('bytes_too_short', 'Data should have at least 42 bytes', {'min_length': 42}), + ('bytes_too_long', 'Data should have at most 42 bytes', {'max_length': 42}), + ('value_error', 'Value error, foobar', {'error': 'foobar'}), + ('assertion_error', 'Assertion failed, foobar', {'error': 'foobar'}), + ('literal_error', 'Input should be foo', {'expected': 'foo'}), + ('literal_error', 'Input should be foo or bar', {'expected': 'foo or bar'}), + ('date_type', 'Input should be a valid date', None), + ('date_parsing', 'Input should be a valid date in the format YYYY-MM-DD, foobar', {'error': 'foobar'}), + ('date_from_datetime_parsing', 'Input should be a valid date or datetime, foobar', {'error': 'foobar'}), + ('date_from_datetime_inexact', 'Datetimes provided to dates should have zero time - e.g. be exact dates', None), + ('date_past', 'Date should be in the past', None), + ('date_future', 'Date should be in the future', None), + ('time_type', 'Input should be a valid time', None), + ('time_parsing', 'Input should be in a valid time format, foobar', {'error': 'foobar'}), + ('datetime_type', 'Input should be a valid datetime', None), + ('datetime_parsing', 'Input should be a valid datetime, foobar', {'error': 'foobar'}), + ('datetime_object_invalid', 'Invalid datetime object, got foobar', {'error': 'foobar'}), + ('datetime_past', 'Datetime should be in the past', None), + ('datetime_future', 'Datetime should be in the future', None), + ('time_delta_type', 'Input should be a valid timedelta', None), + ('time_delta_parsing', 'Input should be a valid timedelta, foobar', {'error': 'foobar'}), + ('frozen_set_type', 'Input should be a valid frozenset', None), + ('is_instance_of', 'Input should be an instance of Foo', {'class': 'Foo'}), + ('is_subclass_of', 'Input should be a subclass of Foo', {'class': 'Foo'}), + ('callable_type', 'Input should be callable', None), + ( + 'union_tag_invalid', + "Input tag 'foo' found using bar does not match any of the expected tags: baz", + {'discriminator': 'bar', 'tag': 'foo', 'expected_tags': 'baz'}, + ), + ('union_tag_not_found', 'Unable to extract tag using discriminator foo', {'discriminator': 'foo'}), + ('arguments_type', 'Arguments must be a tuple, list or a dictionary', None), + ('positional_arguments_type', 'Positional arguments must be a list or tuple', None), + ('keyword_arguments_type', 'Keyword arguments must be a dictionary', None), + ('unexpected_keyword_argument', 'Unexpected keyword argument', None), + ('missing_keyword_argument', 'Missing required keyword argument', None), + ('unexpected_positional_argument', 'Unexpected positional argument', None), + ('missing_positional_argument', 'Missing required positional argument', None), + ('multiple_argument_values', 'Got multiple values for argument', None), +] + + +@pytest.mark.parametrize('kind, message, context', all_errors) def test_error_kind(kind, message, context): e = PydanticKindError(kind, context) assert e.message() == message @@ -266,6 +272,12 @@ def test_error_kind(kind, message, context): assert e.context == context +def test_all_errors_covered(): + listed_kinds = set(kind for kind, *_ in all_errors) + actual_kinds = {e['kind'] for e in list_all_errors()} + assert actual_kinds == listed_kinds + + def test_error_decimal(): e = PydanticKindError('greater_than', {'gt': Decimal('42.1')}) assert e.message() == 'Input should be greater than 42.1' diff --git a/tests/test_misc.py b/tests/test_misc.py index fc2c176db..c4771961f 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -4,6 +4,7 @@ import pytest +from pydantic_core import core_schema from pydantic_core._pydantic_core import ( SchemaError, SchemaValidator, @@ -12,7 +13,6 @@ build_profile, list_all_errors, ) -from pydantic_core.core_schema import ErrorKind @pytest.mark.parametrize('obj', [ValidationError, SchemaValidator, SchemaError]) @@ -174,7 +174,7 @@ def test_all_errors(): }, ] kinds = [e['kind'] for e in errors] - if kinds != list(ErrorKind.__args__): + if kinds != list(core_schema.ErrorKind.__args__): literal = ''.join(f'\n {e!r},' for e in kinds) print(f'python code:\n\nErrorKind = Literal[{literal}\n]') pytest.fail('core_schema.ErrorKind needs to be updated') diff --git a/tests/validators/test_arguments.py b/tests/validators/test_arguments.py index 5db62476e..1ecdf525f 100644 --- a/tests/validators/test_arguments.py +++ b/tests/validators/test_arguments.py @@ -5,7 +5,6 @@ from typing import Any, get_type_hints import pytest -from dirty_equals import IsListOrTuple from pydantic_core import SchemaError, SchemaValidator, ValidationError @@ -15,16 +14,22 @@ @pytest.mark.parametrize( 'input_value,expected', [ - [((1, 'a', True), None), ((1, 'a', True), {})], - [((1, 'a', True), {}), ((1, 'a', True), {})], - [([1, 'a', True], None), ((1, 'a', True), {})], - [((1, 'a', 'true'), None), ((1, 'a', True), {})], + [(1, 'a', True), ((1, 'a', True), {})], + [[1, 'a', True], ((1, 'a', True), {})], + [{'__args__': (1, 'a', True), '__kwargs__': {}}, ((1, 'a', True), {})], + [[1, 'a', True], ((1, 'a', True), {})], + [(1, 'a', 'true'), ((1, 'a', True), {})], ['x', Err('kind=arguments_type,')], - [((1, 'a', True), ()), Err('kind=arguments_type,')], - [(4, {}), Err('kind=arguments_type,')], - [(1, 2, 3), Err('kind=arguments_type,')], [ - ([1, 'a', True], {'x': 1}), + {'__args__': (1, 'a', True), '__kwargs__': ()}, + Err('Keyword arguments must be a dictionary [kind=keyword_arguments_type,'), + ], + [ + {'__args__': {}, '__kwargs__': {}}, + Err('Positional arguments must be a list or tuple [kind=positional_arguments_type,'), + ], + [ + {'__args__': [1, 'a', True], '__kwargs__': {'x': 1}}, Err( '', [ @@ -38,7 +43,7 @@ ), ], [ - ([1], None), + [1], Err( '', [ @@ -46,19 +51,19 @@ 'kind': 'missing_positional_argument', 'loc': [1], 'message': 'Missing required positional argument', - 'input_value': IsListOrTuple([1], None), + 'input_value': [1], }, { 'kind': 'missing_positional_argument', 'loc': [2], 'message': 'Missing required positional argument', - 'input_value': IsListOrTuple([1], None), + 'input_value': [1], }, ], ), ], [ - ([1, 'a', True, 4], None), + [1, 'a', True, 4], Err( '', [ @@ -72,7 +77,7 @@ ), ], [ - ([1, 'a', True, 4, 5], None), + [1, 'a', True, 4, 5], Err( '', [ @@ -92,7 +97,7 @@ ), ], [ - (('x', 'a', 'wrong'), None), + ('x', 'a', 'wrong'), Err( '', [ @@ -112,7 +117,7 @@ ), ], [ - (None, None), + {'__args__': None, '__kwargs__': None}, Err( '3 validation errors for arguments', [ @@ -120,19 +125,19 @@ 'kind': 'missing_positional_argument', 'loc': [0], 'message': 'Missing required positional argument', - 'input_value': IsListOrTuple(None, None), + 'input_value': {'__args__': None, '__kwargs__': None}, }, { 'kind': 'missing_positional_argument', 'loc': [1], 'message': 'Missing required positional argument', - 'input_value': IsListOrTuple(None, None), + 'input_value': {'__args__': None, '__kwargs__': None}, }, { 'kind': 'missing_positional_argument', 'loc': [2], 'message': 'Missing required positional argument', - 'input_value': IsListOrTuple(None, None), + 'input_value': {'__args__': None, '__kwargs__': None}, }, ], ), @@ -160,21 +165,17 @@ def test_positional_args(py_and_json: PyAndJson, input_value, expected): else: assert v.validate_test(input_value) == expected - with pytest.raises(ValidationError, match='kind=arguments_type,'): - # lists are not allowed from python, but no equivalent restriction in JSON - v.validate_python([(1, 'a', True), None]) - @pytest.mark.parametrize( 'input_value,expected', [ - [(None, {'a': 1, 'b': 'a', 'c': True}), ((), {'a': 1, 'b': 'a', 'c': True})], + [{'__args__': None, '__kwargs__': {'a': 1, 'b': 'a', 'c': True}}, ((), {'a': 1, 'b': 'a', 'c': True})], [{'a': 1, 'b': 'a', 'c': True}, ((), {'a': 1, 'b': 'a', 'c': True})], - [(None, {'a': '1', 'b': 'a', 'c': 'True'}), ((), {'a': 1, 'b': 'a', 'c': True})], - [((), {'a': 1, 'b': 'a', 'c': True}), ((), {'a': 1, 'b': 'a', 'c': True})], - [((1,), {'a': 1, 'b': 'a', 'c': True}), Err('kind=unexpected_positional_argument,')], + [{'__args__': None, '__kwargs__': {'a': '1', 'b': 'a', 'c': 'True'}}, ((), {'a': 1, 'b': 'a', 'c': True})], + [{'__args__': (), '__kwargs__': {'a': 1, 'b': 'a', 'c': True}}, ((), {'a': 1, 'b': 'a', 'c': True})], + [{'__args__': (1,), '__kwargs__': {'a': 1, 'b': 'a', 'c': True}}, Err('kind=unexpected_positional_argument,')], [ - ((), {'a': 1, 'b': 'a', 'c': True, 'd': 'wrong'}), + {'__args__': (), '__kwargs__': {'a': 1, 'b': 'a', 'c': True, 'd': 'wrong'}}, Err( 'kind=unexpected_keyword_argument,', [ @@ -188,7 +189,7 @@ def test_positional_args(py_and_json: PyAndJson, input_value, expected): ), ], [ - ([], {'a': 1, 'b': 'a'}), + {'__args__': [], '__kwargs__': {'a': 1, 'b': 'a'}}, Err( 'kind=missing_keyword_argument,', [ @@ -196,13 +197,13 @@ def test_positional_args(py_and_json: PyAndJson, input_value, expected): 'kind': 'missing_keyword_argument', 'loc': ['c'], 'message': 'Missing required keyword argument', - 'input_value': IsListOrTuple([], {'a': 1, 'b': 'a'}), + 'input_value': {'__args__': [], '__kwargs__': {'a': 1, 'b': 'a'}}, } ], ), ], [ - ((), {'a': 'x', 'b': 'a', 'c': 'wrong'}), + {'__args__': (), '__kwargs__': {'a': 'x', 'b': 'a', 'c': 'wrong'}}, Err( '', [ @@ -222,7 +223,7 @@ def test_positional_args(py_and_json: PyAndJson, input_value, expected): ), ], [ - (None, None), + {'__args__': None, '__kwargs__': None}, Err( '', [ @@ -230,19 +231,19 @@ def test_positional_args(py_and_json: PyAndJson, input_value, expected): 'kind': 'missing_keyword_argument', 'loc': ['a'], 'message': 'Missing required keyword argument', - 'input_value': IsListOrTuple(None, None), + 'input_value': {'__args__': None, '__kwargs__': None}, }, { 'kind': 'missing_keyword_argument', 'loc': ['b'], 'message': 'Missing required keyword argument', - 'input_value': IsListOrTuple(None, None), + 'input_value': {'__args__': None, '__kwargs__': None}, }, { 'kind': 'missing_keyword_argument', 'loc': ['c'], 'message': 'Missing required keyword argument', - 'input_value': IsListOrTuple(None, None), + 'input_value': {'__args__': None, '__kwargs__': None}, }, ], ), @@ -274,11 +275,12 @@ def test_keyword_args(py_and_json: PyAndJson, input_value, expected): @pytest.mark.parametrize( 'input_value,expected', [ - [(None, {'a': 1, 'b': 'bb', 'c': True}), ((), {'a': 1, 'b': 'bb', 'c': True})], - [((1, 'bb'), {'c': True}), ((1, 'bb'), {'c': True})], - [((1,), {'b': 'bb', 'c': True}), ((1,), {'b': 'bb', 'c': True})], + [{'a': 1, 'b': 'bb', 'c': True}, ((), {'a': 1, 'b': 'bb', 'c': True})], + [{'__args__': None, '__kwargs__': {'a': 1, 'b': 'bb', 'c': True}}, ((), {'a': 1, 'b': 'bb', 'c': True})], + [{'__args__': (1, 'bb'), '__kwargs__': {'c': True}}, ((1, 'bb'), {'c': True})], + [{'__args__': (1,), '__kwargs__': {'b': 'bb', 'c': True}}, ((1,), {'b': 'bb', 'c': True})], [ - ((1,), {'a': 11, 'b': 'bb', 'c': True}), + {'__args__': (1,), '__kwargs__': {'a': 11, 'b': 'bb', 'c': True}}, Err( 'kind=multiple_argument_values,', [ @@ -292,7 +294,7 @@ def test_keyword_args(py_and_json: PyAndJson, input_value, expected): ), ], [ - ([1, 'bb', 'cc'], {'b': 'bb', 'c': True}), + {'__args__': [1, 'bb', 'cc'], '__kwargs__': {'b': 'bb', 'c': True}}, Err( 'kind=unexpected_positional_argument,', [ @@ -312,7 +314,7 @@ def test_keyword_args(py_and_json: PyAndJson, input_value, expected): ), ], [ - ((1, 'b1'), {'a': 11, 'b': 'b2', 'c': True}), + {'__args__': (1, 'b1'), '__kwargs__': {'a': 11, 'b': 'b2', 'c': True}}, Err( 'kind=multiple_argument_values,', [ @@ -355,7 +357,7 @@ def test_positional_or_keyword(py_and_json: PyAndJson, input_value, expected): assert v.validate_test(input_value) == expected -@pytest.mark.parametrize('input_value,expected', [[((1,), None), ((1,), {})], [((), None), ((42,), {})]], ids=repr) +@pytest.mark.parametrize('input_value,expected', [[(1,), ((1,), {})], [(), ((42,), {})]], ids=repr) def test_positional_optional(py_and_json: PyAndJson, input_value, expected): v = py_and_json( { @@ -382,10 +384,11 @@ def test_positional_optional(py_and_json: PyAndJson, input_value, expected): @pytest.mark.parametrize( 'input_value,expected', [ - [(None, {'a': 1}), ((), {'a': 1})], - [(None, None), ((), {'a': 1})], - [((), {'a': 1}), ((), {'a': 1})], - [((), None), ((), {'a': 1})], + [{'a': 1}, ((), {'a': 1})], + [{'__args__': None, '__kwargs__': {'a': 1}}, ((), {'a': 1})], + [{'__args__': None, '__kwargs__': None}, ((), {'a': 1})], + [{'__args__': (), '__kwargs__': {'a': 1}}, ((), {'a': 1})], + [{'__args__': (), '__kwargs__': None}, ((), {'a': 1})], ], ids=repr, ) @@ -415,11 +418,15 @@ def test_p_or_k_optional(py_and_json: PyAndJson, input_value, expected): @pytest.mark.parametrize( 'input_value,expected', [ - [([1, 2, 3], None), ((1, 2, 3), {})], - [([1], None), ((1,), {})], - [([], None), ((), {})], - [([], {}), ((), {})], - [([1, 2, 3], {'a': 1}), Err('a\n Unexpected keyword argument [kind=unexpected_keyword_argument,')], + [[1, 2, 3], ((1, 2, 3), {})], + [{'__args__': [1, 2, 3], '__kwargs__': None}, ((1, 2, 3), {})], + [[1], ((1,), {})], + [[], ((), {})], + [{'__args__': [], '__kwargs__': {}}, ((), {})], + [ + {'__args__': [1, 2, 3], '__kwargs__': {'a': 1}}, + Err('a\n Unexpected keyword argument [kind=unexpected_keyword_argument,'), + ], ], ids=repr, ) @@ -438,12 +445,12 @@ def test_var_args_only(py_and_json: PyAndJson, input_value, expected): @pytest.mark.parametrize( 'input_value,expected', [ - [([1, 2, 3], None), ((1, 2, 3), {})], - [(['1', '2', '3'], None), ((1, 2, 3), {})], - [([1], None), ((1,), {})], - [([], None), Err('0\n Missing required positional argument')], + [[1, 2, 3], ((1, 2, 3), {})], + [['1', '2', '3'], ((1, 2, 3), {})], + [[1], ((1,), {})], + [[], Err('0\n Missing required positional argument')], [ - (['x'], None), + ['x'], Err( 'kind=int_parsing,', [ @@ -457,7 +464,7 @@ def test_var_args_only(py_and_json: PyAndJson, input_value, expected): ), ], [ - ([1, 'x', 'y'], None), + [1, 'x', 'y'], Err( 'kind=int_parsing,', [ @@ -476,7 +483,10 @@ def test_var_args_only(py_and_json: PyAndJson, input_value, expected): ], ), ], - [([1, 2, 3], {'a': 1}), Err('a\n Unexpected keyword argument [kind=unexpected_keyword_argument,')], + [ + {'__args__': [1, 2, 3], '__kwargs__': {'a': 1}}, + Err('a\n Unexpected keyword argument [kind=unexpected_keyword_argument,'), + ], ], ids=repr, ) @@ -501,10 +511,13 @@ def test_args_var_args_only(py_and_json: PyAndJson, input_value, expected): @pytest.mark.parametrize( 'input_value,expected', [ - [([1, 'a', 'true'], {'b': 'bb', 'c': 3}), ((1, 'a', True), {'b': 'bb', 'c': 3})], - [([1, 'a'], {'a': 'true', 'b': 'bb', 'c': 3}), ((1, 'a'), {'a': True, 'b': 'bb', 'c': 3})], + [{'__args__': [1, 'a', 'true'], '__kwargs__': {'b': 'bb', 'c': 3}}, ((1, 'a', True), {'b': 'bb', 'c': 3})], [ - ([1, 'a', 'true', 4, 5], {'b': 'bb', 'c': 3}), + {'__args__': [1, 'a'], '__kwargs__': {'a': 'true', 'b': 'bb', 'c': 3}}, + ((1, 'a'), {'a': True, 'b': 'bb', 'c': 3}), + ], + [ + {'__args__': [1, 'a', 'true', 4, 5], '__kwargs__': {'b': 'bb', 'c': 3}}, Err( 'kind=unexpected_positional_argument,', [ @@ -551,14 +564,14 @@ def test_both(py_and_json: PyAndJson, input_value, expected): @pytest.mark.parametrize( 'input_value,expected', [ - [([], {}), ((), {})], - [(None, None), ((), {})], - [(None, {}), ((), {})], - [([], None), ((), {})], - [([1], None), Err('0\n Unexpected positional argument [kind=unexpected_positional_argument,')], - [([], {'a': 1}), Err('a\n Unexpected keyword argument [kind=unexpected_keyword_argument,')], + [{'__args__': [], '__kwargs__': {}}, ((), {})], + [{'__args__': None, '__kwargs__': None}, ((), {})], + [{'__args__': None, '__kwargs__': {}}, ((), {})], + [[], ((), {})], + [[1], Err('0\n Unexpected positional argument [kind=unexpected_positional_argument,')], + [{'a': 1}, Err('a\n Unexpected keyword argument [kind=unexpected_keyword_argument,')], [ - ([1], {'a': 2}), + {'__args__': [1], '__kwargs__': {'a': 2}}, Err( '[kind=unexpected_keyword_argument,', [ @@ -612,18 +625,18 @@ def test_internal_error(py_and_json: PyAndJson): ], } ) - assert v.validate_test(((1, 2), None)) == ((1, 4), {}) + assert v.validate_test((1, 2)) == ((1, 4), {}) with pytest.raises(RuntimeError, match='bust'): - v.validate_test(((1, 1), None)) + v.validate_test((1, 1)) @pytest.mark.parametrize( 'input_value,expected', [ - [((1, 2), None), ((1, 2), {})], - [((1,), None), ((1,), {'b': 42})], - [((1,), {'b': 3}), ((1,), {'b': 3})], - [(None, {'a': 1}), ((), {'a': 1, 'b': 42})], + [{'__args__': (1, 2), '__kwargs__': None}, ((1, 2), {})], + [{'__args__': (1,), '__kwargs__': None}, ((1,), {'b': 42})], + [{'__args__': (1,), '__kwargs__': {'b': 3}}, ((1,), {'b': 3})], + [{'__args__': None, '__kwargs__': {'a': 1}}, ((), {'a': 1, 'b': 42})], ], ids=repr, ) @@ -681,9 +694,9 @@ def test_build_non_default_follows(): @pytest.mark.parametrize( 'input_value,expected', [ - [((1, 2), None), ((1, 2), {})], - [((1,), {'b': '4', 'c': 'a'}), ((1,), {'b': 4, 'c': 'a'})], - [((1, 2), {'x': 'abc'}), ((1, 2), {'x': 'abc'})], + [{'__args__': (1, 2), '__kwargs__': None}, ((1, 2), {})], + [{'__args__': (1,), '__kwargs__': {'b': '4', 'c': 'a'}}, ((1,), {'b': 4, 'c': 'a'})], + [{'__args__': (1, 2), '__kwargs__': {'x': 'abc'}}, ((1, 2), {'x': 'abc'})], ], ids=repr, ) @@ -708,9 +721,12 @@ def test_kwargs(py_and_json: PyAndJson, input_value, expected): @pytest.mark.parametrize( 'input_value,expected', [ - [((1,), None), ((1,), {})], - [(None, {'Foo': 1}), ((), {'a': 1})], - [(None, {'a': 1}), Err('a\n Missing required keyword argument [kind=missing_keyword_argument,')], + [{'__args__': (1,), '__kwargs__': None}, ((1,), {})], + [{'__args__': None, '__kwargs__': {'Foo': 1}}, ((), {'a': 1})], + [ + {'__args__': None, '__kwargs__': {'a': 1}}, + Err('a\n Missing required keyword argument [kind=missing_keyword_argument,'), + ], ], ids=repr, ) @@ -733,11 +749,17 @@ def test_alias(py_and_json: PyAndJson, input_value, expected): @pytest.mark.parametrize( 'input_value,expected', [ - [((1,), None), ((1,), {})], - [(None, {'Foo': 1}), ((), {'a': 1})], - [(None, {'a': 1}), ((), {'a': 1})], - [(None, {'a': 1, 'b': 2}), Err('b\n Unexpected keyword argument [kind=unexpected_keyword_argument,')], - [(None, {'a': 1, 'Foo': 2}), Err('a\n Unexpected keyword argument [kind=unexpected_keyword_argument,')], + [{'__args__': (1,), '__kwargs__': None}, ((1,), {})], + [{'__args__': None, '__kwargs__': {'Foo': 1}}, ((), {'a': 1})], + [{'__args__': None, '__kwargs__': {'a': 1}}, ((), {'a': 1})], + [ + {'__args__': None, '__kwargs__': {'a': 1, 'b': 2}}, + Err('b\n Unexpected keyword argument [kind=unexpected_keyword_argument,'), + ], + [ + {'__args__': None, '__kwargs__': {'a': 1, 'Foo': 2}}, + Err('a\n Unexpected keyword argument [kind=unexpected_keyword_argument,'), + ], ], ids=repr, ) @@ -801,7 +823,7 @@ def validate(function): @wraps(function) def wrapper(*args, **kwargs): - validated_args, validated_kwargs = validator.validate_python((args, kwargs)) + validated_args, validated_kwargs = validator.validate_python({'__args__': args, '__kwargs__': kwargs}) return function(*validated_args, **validated_kwargs) return wrapper @@ -849,7 +871,7 @@ def foobar(a: int, b: int, *, c: int): 'kind': 'missing_keyword_argument', 'loc': ['c'], 'message': 'Missing required keyword argument', - 'input_value': ((1, 'b'), {}), + 'input_value': {'__args__': (1, 'b'), '__kwargs__': {}}, }, ] @@ -894,7 +916,7 @@ def foobar(a: int, b: int, /, c: int): 'kind': 'missing_positional_argument', 'loc': [1], 'message': 'Missing required positional argument', - 'input_value': (('1',), {'b': 2, 'c': 3}), + 'input_value': {'__args__': ('1',), '__kwargs__': {'b': 2, 'c': 3}}, }, { 'kind': 'unexpected_keyword_argument', diff --git a/tests/validators/test_call.py b/tests/validators/test_call.py index 26c69a62c..f65703b9a 100644 --- a/tests/validators/test_call.py +++ b/tests/validators/test_call.py @@ -12,12 +12,12 @@ @pytest.mark.parametrize( 'input_value,expected', [ - [((1, 2, 3), None), 6], - [(None, {'a': 1, 'b': 1, 'c': 1}), 3], - [((1,), {'b': 1, 'c': 1}), 3], - [((1, 2, 'x'), None), Err('arguments -> 2\n Input should be a valid integer,')], - [((3, 3, 4), None), 10], - [((3, 3, 5), None), Err('return-value\n Input should be less than or equal to 10')], + [(1, 2, 3), 6], + [{'a': 1, 'b': 1, 'c': 1}, 3], + [{'__args__': (1,), '__kwargs__': {'b': 1, 'c': 1}}, 3], + [(1, 2, 'x'), Err('arguments -> 2\n Input should be a valid integer,')], + [(3, 3, 4), 10], + [(3, 3, 5), Err('return-value\n Input should be less than or equal to 10')], ], ) def test_function_call_arguments(py_and_json: PyAndJson, input_value, expected): @@ -108,9 +108,9 @@ def my_function(a): ], } ) - assert v.validate_python(((1,), {})) == 1 + assert v.validate_python((1,)) == 1 with pytest.raises(ValidationError) as exc_info: - v.validate_python(((1, 2), {})) + v.validate_python((1, 2)) assert exc_info.value.errors() == [ { 'kind': 'unexpected_positional_argument', @@ -140,11 +140,11 @@ class my_dataclass: }, } ) - d = v.validate_python((('1', b'2'), {})) + d = v.validate_python(('1', b'2')) assert dataclasses.is_dataclass(d) assert d.a == 1 assert d.b == '2' - d = v.validate_python(((), {'a': 1, 'b': '2'})) + d = v.validate_python({'a': 1, 'b': '2'}) assert dataclasses.is_dataclass(d) assert d.a == 1 assert d.b == '2' @@ -167,7 +167,7 @@ def test_named_tuple(): }, } ) - d = v.validate_python((('1.1', '2.2'), {})) + d = v.validate_python(('1.1', '2.2')) assert isinstance(d, Point) assert d.x == 1.1 assert d.y == 2.2 diff --git a/tests/validators/test_with_default.py b/tests/validators/test_with_default.py index b69758e06..c430598fc 100644 --- a/tests/validators/test_with_default.py +++ b/tests/validators/test_with_default.py @@ -49,9 +49,9 @@ def test_arguments(): ], } ) - assert v.validate_python(((), {'a': 2})) == ((), {'a': 2}) - assert v.validate_python(((2,), {})) == ((2,), {}) - assert v.validate_python(((), {})) == ((), {'a': 1}) + assert v.validate_python({'a': 2}) == ((), {'a': 2}) + assert v.validate_python({'__args__': (2,), '__kwargs__': {}}) == ((2,), {}) + assert v.validate_python(()) == ((), {'a': 1}) def test_arguments_omit(): From e0fbe76edb402567fc96a8e36c33c9f5ad078a6f Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Thu, 20 Oct 2022 14:45:21 +0100 Subject: [PATCH 2/2] tests for missing and extra dict keys with __args__/__kwargs__ --- tests/validators/test_arguments.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/validators/test_arguments.py b/tests/validators/test_arguments.py index 1ecdf525f..dcaa09392 100644 --- a/tests/validators/test_arguments.py +++ b/tests/validators/test_arguments.py @@ -718,6 +718,27 @@ def test_kwargs(py_and_json: PyAndJson, input_value, expected): assert v.validate_test(input_value) == expected +@pytest.mark.parametrize( + 'input_value,expected', + [ + [{'__args__': [1, 2]}, ((), {'__args__': [1, 2]})], + [{'__kwargs__': {'x': 'abc'}}, ((), {'__kwargs__': {'x': 'abc'}})], + [ + {'__args__': [1, 2], '__kwargs__': {'x': 'abc'}, 'more': 'hello'}, + ((), {'__args__': [1, 2], '__kwargs__': {'x': 'abc'}, 'more': 'hello'}), + ], + ], + ids=repr, +) +def test_var_kwargs(py_and_json: PyAndJson, input_value, expected): + v = py_and_json({'type': 'arguments', 'arguments_schema': [], 'var_kwargs_schema': {'type': 'any'}}) + if isinstance(expected, Err): + with pytest.raises(ValidationError, match=re.escape(expected.message)): + v.validate_test(input_value) + else: + assert v.validate_test(input_value) == expected + + @pytest.mark.parametrize( 'input_value,expected', [