From 2650977c38b3f9b7de6e1f1984151197f0cffc4a Mon Sep 17 00:00:00 2001 From: Dylan Baker Date: Wed, 27 Jan 2021 11:06:39 -0800 Subject: [PATCH] interpreterbase: Add support for optional arguments to typed_pos_args This allows representing functions like assert(), which take optional positional arguments, which are not variadic. More importnatly you can represent a function like (* means optional, but possitional): ```txt func(str, *int, *str) ``` typed_pos_args will check that all of your types are correct, and if not provide None, which allow simplifying a number of implementation details --- mesonbuild/interpreterbase.py | 40 +++++++++++++++++++++-- run_unittests.py | 60 +++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/mesonbuild/interpreterbase.py b/mesonbuild/interpreterbase.py index 081a31d34..0dadad28b 100644 --- a/mesonbuild/interpreterbase.py +++ b/mesonbuild/interpreterbase.py @@ -232,9 +232,27 @@ class permittedKwargs: def typed_pos_args(name: str, *types: T.Union[T.Type, T.Tuple[T.Type, ...]], varargs: T.Optional[T.Union[T.Type, T.Tuple[T.Type]]] = None, + optargs: T.Optional[T.List[T.Union[T.Type, T.Tuple[T.Type]]]] = None, min_varargs: int = 0, max_varargs: int = 0) -> T.Callable[..., T.Any]: """Decorator that types type checking of positional arguments. + This supports two different models of optional aguments, the first is the + variadic argument model. Variadic arguments are a possibly bounded, + possibly unbounded number of arguments of the same type (unions are + supported). The second is the standard default value model, in this case + a number of optional arguments may be provided, but they are still + ordered, and they may have different types. + + This function does not support mixing variadic and default arguments. + + :name: The name of the decorated function (as displayed in error messages) + :varargs: They type(s) of any variadic arguments the function takes. If + None the function takes no variadic args + :min_varargs: the minimum number of variadic arguments taken + :max_varargs: the maximum number of variadic arguments taken. 0 means unlimited + :optargs: The types of any optional arguments parameters taken. If None + then no optional paramters are taken. + allows replacing this: ```python def func(self, node, args, kwargs): @@ -268,9 +286,12 @@ def typed_pos_args(name: str, *types: T.Union[T.Type, T.Tuple[T.Type, ...]], assert isinstance(args, list), args assert max_varargs >= 0, 'max_varrags cannot be negative' assert min_varargs >= 0, 'min_varrags cannot be negative' + assert optargs is None or varargs is None, \ + 'varargs and optargs not supported together as this would be ambiguous' num_args = len(args) num_types = len(types) + a_types = types if varargs: num_types += min_varargs @@ -278,10 +299,19 @@ def typed_pos_args(name: str, *types: T.Union[T.Type, T.Tuple[T.Type, ...]], raise InvalidArguments(f'{name} takes at least {num_types} arguments, but got {num_args}.') elif max_varargs != 0 and (num_args < num_types or num_args > num_types + max_varargs - min_varargs): raise InvalidArguments(f'{name} takes between {num_types} and {num_types + max_varargs - min_varargs} arguments, but got {len(args)}.') + elif optargs: + if num_args < num_types: + raise InvalidArguments(f'{name} takes at least {num_types} arguments, but got {num_args}.') + elif num_args > num_types + len(optargs): + raise InvalidArguments(f'{name} takes at most {num_types + len(optargs)} arguments, but got {num_args}.') + # Add the number of positional arguments required + if num_args > num_types: + diff = num_args - num_types + a_types = tuple(list(types) + list(optargs[:diff])) elif num_args != num_types: raise InvalidArguments(f'{name} takes exactly {num_types} arguments, but got {num_args}.') - for i, (arg, type_) in enumerate(itertools.zip_longest(args, types, fillvalue=varargs), start=1): + for i, (arg, type_) in enumerate(itertools.zip_longest(args, a_types, fillvalue=varargs), start=1): if not isinstance(arg, type_): if isinstance(type_, tuple): shouldbe = 'one of: {}'.format(", ".join(f'"{t.__name__}"' for t in type_)) @@ -298,11 +328,17 @@ def typed_pos_args(name: str, *types: T.Union[T.Type, T.Tuple[T.Type, ...]], # if we have varargs we need to split them into a separate # tuple, as python's typing doesn't understand tuples with # fixed elements and variadic elements, only one or the other. - # so in that cas we need T.Tuple[int, str, float, T.Tuple[str, ...]] + # so in that case we need T.Tuple[int, str, float, T.Tuple[str, ...]] pos = args[:len(types)] var = list(args[len(types):]) pos.append(var) nargs[i] = tuple(pos) + elif optargs: + if num_args < num_types + len(optargs): + diff = num_types + len(optargs) - num_args + nargs[i] = tuple(list(args) + [None] * diff) + else: + nargs[i] = args else: nargs[i] = tuple(args) return f(*nargs, **wrapped_kwargs) diff --git a/run_unittests.py b/run_unittests.py index 8dfb39447..b2e73382f 100755 --- a/run_unittests.py +++ b/run_unittests.py @@ -1422,6 +1422,66 @@ class InternalTests(unittest.TestCase): _(None, mock.Mock(), ['string'], None) self.assertEqual(str(cm.exception), 'foo takes between 2 and 3 arguments, but got 1.') + def test_typed_pos_args_variadic_and_optional(self) -> None: + @typed_pos_args('foo', str, optargs=[str], varargs=str, min_varargs=0) + def _(obj, node, args: T.Tuple[str, T.List[str]], kwargs) -> None: + self.assertTrue(False) # should not be reachable + + with self.assertRaises(AssertionError) as cm: + _(None, mock.Mock(), ['string'], None) + self.assertEqual( + str(cm.exception), + 'varargs and optargs not supported together as this would be ambiguous') + + def test_typed_pos_args_min_optargs_not_met(self) -> None: + @typed_pos_args('foo', str, str, optargs=[str]) + def _(obj, node, args: T.Tuple[str, T.Optional[str]], kwargs) -> None: + self.assertTrue(False) # should not be reachable + + with self.assertRaises(InvalidArguments) as cm: + _(None, mock.Mock(), ['string'], None) + self.assertEqual(str(cm.exception), 'foo takes at least 2 arguments, but got 1.') + + def test_typed_pos_args_min_optargs_max_exceeded(self) -> None: + @typed_pos_args('foo', str, optargs=[str]) + def _(obj, node, args: T.Tuple[str, T.Optional[str]], kwargs) -> None: + self.assertTrue(False) # should not be reachable + + with self.assertRaises(InvalidArguments) as cm: + _(None, mock.Mock(), ['string', '1', '2'], None) + self.assertEqual(str(cm.exception), 'foo takes at most 2 arguments, but got 3.') + + def test_typed_pos_args_optargs_not_given(self) -> None: + @typed_pos_args('foo', str, optargs=[str]) + def _(obj, node, args: T.Tuple[str, T.Optional[str]], kwargs) -> None: + self.assertEqual(len(args), 2) + self.assertIsInstance(args[0], str) + self.assertEqual(args[0], 'string') + self.assertIsNone(args[1]) + + _(None, mock.Mock(), ['string'], None) + + def test_typed_pos_args_optargs_some_given(self) -> None: + @typed_pos_args('foo', str, optargs=[str, int]) + def _(obj, node, args: T.Tuple[str, T.Optional[str], T.Optional[int]], kwargs) -> None: + self.assertEqual(len(args), 3) + self.assertIsInstance(args[0], str) + self.assertEqual(args[0], 'string') + self.assertIsInstance(args[1], str) + self.assertEqual(args[1], '1') + self.assertIsNone(args[2]) + + _(None, mock.Mock(), ['string', '1'], None) + + def test_typed_pos_args_optargs_all_given(self) -> None: + @typed_pos_args('foo', str, optargs=[str]) + def _(obj, node, args: T.Tuple[str, T.Optional[str]], kwargs) -> None: + self.assertEqual(len(args), 2) + self.assertIsInstance(args[0], str) + self.assertEqual(args[0], 'string') + self.assertIsInstance(args[1], str) + + _(None, mock.Mock(), ['string', '1'], None) @unittest.skipIf(is_tarball(), 'Skipping because this is a tarball release') class DataTests(unittest.TestCase):