aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDylan Baker <dylan@pnwbakers.com>2021-02-08 11:37:21 -0800
committerDylan Baker <dylan@pnwbakers.com>2021-05-30 23:32:15 -0700
commit521b92e499b9ce093552f47d4780343f9f725029 (patch)
treed1096551f11227254bb1db6928a3cfc9ef572521
parent7a6ad2953ab9750569db9de421180c3fb657fb1a (diff)
downloadmeson-521b92e499b9ce093552f47d4780343f9f725029.zip
meson-521b92e499b9ce093552f47d4780343f9f725029.tar.gz
meson-521b92e499b9ce093552f47d4780343f9f725029.tar.bz2
interpreterbase: Add a function for type checking keyword arguments
-rw-r--r--mesonbuild/interpreterbase.py130
-rwxr-xr-xrun_unittests.py103
2 files changed, 233 insertions, 0 deletions
diff --git a/mesonbuild/interpreterbase.py b/mesonbuild/interpreterbase.py
index 41732d6..ed0f9fa 100644
--- a/mesonbuild/interpreterbase.py
+++ b/mesonbuild/interpreterbase.py
@@ -357,6 +357,136 @@ def typed_pos_args(name: str, *types: T.Union[T.Type, T.Tuple[T.Type, ...]],
return inner
+class ContainerTypeInfo:
+
+ """Container information for keyword arguments.
+
+ For keyword arguments that are containers (list or dict), this class encodes
+ that information.
+
+ :param container: the type of container
+ :param contains: the types the container holds
+ :param pairs: if the container is supposed to be of even length.
+ This is mainly used for interfaces that predate the addition of dictionaries, and use
+ `[key, value, key2, value2]` format.
+ :param allow_empty: Whether this container is allowed to be empty
+ There are some cases where containers not only must be passed, but must
+ not be empty, and other cases where an empty container is allowed.
+ """
+
+ def __init__(self, container: T.Type, contains: T.Union[T.Type, T.Tuple[T.Type, ...]], *,
+ pairs: bool = False, allow_empty: bool = True) :
+ self.container = container
+ self.contains = contains
+ self.pairs = pairs
+ self.allow_empty = allow_empty
+
+ def check(self, value: T.Any) -> T.Optional[str]:
+ """Check that a value is valid.
+
+ :param value: A value to check
+ :return: If there is an error then a string message, otherwise None
+ """
+ if not isinstance(value, self.container):
+ return f'container type was "{type(value).__name__}", but should have been "{self.container.__name__}"'
+ iter_ = iter(value.values()) if isinstance(value, dict) else iter(value)
+ for each in iter_:
+ if not isinstance(each, self.contains):
+ if isinstance(self.contains, tuple):
+ shouldbe = 'one of: {}'.format(", ".join(f'"{t.__name__}"' for t in self.contains))
+ else:
+ shouldbe = f'"{self.contains.__name__}"'
+ return f'contained a value of type "{type(each).__name__}" but should have been {shouldbe}'
+ if self.pairs and len(value) % 2 != 0:
+ return 'container should be of even length, but is not'
+ if not value and not self.allow_empty:
+ return 'container is empty, but not allowed to be'
+ return None
+
+
+_T = T.TypeVar('_T')
+
+
+class KwargInfo(T.Generic[_T]):
+
+ """A description of a keyword argument to a meson function
+
+ This is used to describe a value to the :func:typed_kwargs function.
+
+ :param name: the name of the parameter
+ :param types: A type or tuple of types that are allowed, or a :class:ContainerType
+ :param required: Whether this is a required keyword argument. defaults to False
+ :param listify: If true, then the argument will be listified before being
+ checked. This is useful for cases where the Meson DSL allows a scalar or
+ a container, but internally we only want to work with containers
+ :param default: A default value to use if this isn't set. defaults to None
+ """
+
+ def __init__(self, name: str, types: T.Union[T.Type[_T], T.Tuple[T.Type[_T], ...], ContainerTypeInfo],
+ required: bool = False, listify: bool = False, default: T.Optional[_T] = None):
+ self.name = name
+ self.types = types
+ self.required = required
+ self.listify = listify
+ self.default = default
+
+
+def typed_kwargs(name: str, *types: KwargInfo) -> T.Callable[..., T.Any]:
+ """Decorator for type checking keyword arguments.
+
+ Used to wrap a meson DSL implementation function, where it checks various
+ things about keyword arguments, including the type, and various other
+ information. For non-required values it sets the value to a default, which
+ means the value will always be provided.
+
+ :param name: the name of the function, including the object it's attached ot
+ (if applicable)
+ :param *types: KwargInfo entries for each keyword argument.
+ """
+ def inner(f: TV_func) -> TV_func:
+
+ @wraps(f)
+ def wrapper(*wrapped_args: T.Any, **wrapped_kwargs: T.Any) -> T.Any:
+ kwargs = _get_callee_args(wrapped_args)[3]
+
+ all_names = {t.name for t in types}
+ unknowns = set(kwargs).difference(all_names)
+ if unknowns:
+ # Warn about unknown argumnts, delete them and continue. This
+ # keeps current behavior
+ ustr = ', '.join([f'"{u}"' for u in sorted(unknowns)])
+ mlog.warning(f'{name} got unknown keyword arguments {ustr}')
+ for u in unknowns:
+ del kwargs[u]
+
+ for info in types:
+ if info.name in kwargs:
+ value = kwargs[info.name]
+ if info.listify:
+ kwargs[info.name] = value = mesonlib.listify(value)
+ if isinstance(info.types, ContainerTypeInfo):
+ msg = info.types.check(value)
+ if msg is not None:
+ raise InvalidArguments(f'{name} keyword argument "{info.name}" {msg}')
+ else:
+ if not isinstance(value, info.types):
+ if isinstance(info.types, tuple):
+ shouldbe = 'one of: {}'.format(", ".join(f'"{t.__name__}"' for t in info.types))
+ else:
+ shouldbe = f'"{info.types.__name__}"'
+ raise InvalidArguments(f'{name} keyword argument "{info.name}"" was of type "{type(value).__name__}" but should have been {shouldbe}')
+ elif info.required:
+ raise InvalidArguments(f'{name} is missing required keyword argument "{info.name}"')
+ else:
+ # set the value to the default, this ensuring all kwargs are present
+ # This both simplifies the typing checking and the usage
+ kwargs[info.name] = info.default
+
+ return f(*wrapped_args, **wrapped_kwargs)
+ return T.cast(TV_func, wrapper)
+ return inner
+
+
class FeatureCheckBase(metaclass=abc.ABCMeta):
"Base class for feature version checks"
diff --git a/run_unittests.py b/run_unittests.py
index 0b18ef5..1c6d491 100755
--- a/run_unittests.py
+++ b/run_unittests.py
@@ -52,6 +52,7 @@ import mesonbuild.coredata
import mesonbuild.modules.gnome
from mesonbuild.interpreter import Interpreter
from mesonbuild.interpreterbase import typed_pos_args, InvalidArguments, ObjectHolder
+from mesonbuild.interpreterbase import typed_pos_args, InvalidArguments, typed_kwargs, ContainerTypeInfo, KwargInfo
from mesonbuild.ast import AstInterpreter
from mesonbuild.mesonlib import (
BuildDirLock, LibType, MachineChoice, PerMachine, Version, is_windows,
@@ -1484,6 +1485,108 @@ class InternalTests(unittest.TestCase):
_(None, mock.Mock(), ['string', '1'], None)
+ def test_typed_kwarg_basic(self) -> None:
+ @typed_kwargs(
+ 'testfunc',
+ KwargInfo('input', str)
+ )
+ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, str]) -> None:
+ self.assertIsInstance(kwargs['input'], str)
+ self.assertEqual(kwargs['input'], 'foo')
+
+ _(None, mock.Mock(), [], {'input': 'foo'})
+
+ def test_typed_kwarg_missing_required(self) -> None:
+ @typed_kwargs(
+ 'testfunc',
+ KwargInfo('input', str, required=True),
+ )
+ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, str]) -> None:
+ self.assertTrue(False) # should be unreachable
+
+ with self.assertRaises(InvalidArguments) as cm:
+ _(None, mock.Mock(), [], {})
+ self.assertEqual(str(cm.exception), 'testfunc is missing required keyword argument "input"')
+
+ def test_typed_kwarg_missing_optional(self) -> None:
+ @typed_kwargs(
+ 'testfunc',
+ KwargInfo('input', str),
+ )
+ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, T.Optional[str]]) -> None:
+ self.assertIsNone(kwargs['input'])
+
+ _(None, mock.Mock(), [], {})
+
+ def test_typed_kwarg_default(self) -> None:
+ @typed_kwargs(
+ 'testfunc',
+ KwargInfo('input', str, default='default'),
+ )
+ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, str]) -> None:
+ self.assertEqual(kwargs['input'], 'default')
+
+ _(None, mock.Mock(), [], {})
+
+ def test_typed_kwarg_container_valid(self) -> None:
+ @typed_kwargs(
+ 'testfunc',
+ KwargInfo('input', ContainerTypeInfo(list, str), required=True),
+ )
+ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, T.List[str]]) -> None:
+ self.assertEqual(kwargs['input'], ['str'])
+
+ _(None, mock.Mock(), [], {'input': ['str']})
+
+ def test_typed_kwarg_container_invalid(self) -> None:
+ @typed_kwargs(
+ 'testfunc',
+ KwargInfo('input', ContainerTypeInfo(list, str), required=True),
+ )
+ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, T.List[str]]) -> None:
+ self.assertTrue(False) # should be unreachable
+
+ with self.assertRaises(InvalidArguments) as cm:
+ _(None, mock.Mock(), [], {'input': {}})
+ self.assertEqual(str(cm.exception), 'testfunc keyword argument "input" container type was "dict", but should have been "list"')
+
+ def test_typed_kwarg_contained_invalid(self) -> None:
+ @typed_kwargs(
+ 'testfunc',
+ KwargInfo('input', ContainerTypeInfo(dict, str), required=True),
+ )
+ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, T.Dict[str, str]]) -> None:
+ self.assertTrue(False) # should be unreachable
+
+ with self.assertRaises(InvalidArguments) as cm:
+ _(None, mock.Mock(), [], {'input': {'key': 1}})
+ self.assertEqual(str(cm.exception), 'testfunc keyword argument "input" contained a value of type "int" but should have been "str"')
+
+ def test_typed_kwarg_container_listify(self) -> None:
+ @typed_kwargs(
+ 'testfunc',
+ KwargInfo('input', ContainerTypeInfo(list, str), listify=True),
+ )
+ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, T.List[str]]) -> None:
+ self.assertEqual(kwargs['input'], ['str'])
+
+ _(None, mock.Mock(), [], {'input': 'str'})
+
+ def test_typed_kwarg_container_pairs(self) -> None:
+ @typed_kwargs(
+ 'testfunc',
+ KwargInfo('input', ContainerTypeInfo(list, str, pairs=True), listify=True),
+ )
+ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, T.List[str]]) -> None:
+ self.assertEqual(kwargs['input'], ['a', 'b'])
+
+ _(None, mock.Mock(), [], {'input': ['a', 'b']})
+
+ with self.assertRaises(MesonException) as cm:
+ _(None, mock.Mock(), [], {'input': ['a']})
+ self.assertEqual(str(cm.exception), "testfunc keyword argument \"input\" container should be of even length, but is not")
+
+
@unittest.skipIf(is_tarball(), 'Skipping because this is a tarball release')
class DataTests(unittest.TestCase):