diff --git a/ibis/common/annotations.py b/ibis/common/annotations.py index 297e57fd8cca..41c98d4b6e86 100644 --- a/ibis/common/annotations.py +++ b/ibis/common/annotations.py @@ -91,7 +91,8 @@ def __str__(self): errors += f"\n `{name}`: {value!r} of type {type(value)} is not {pattern.describe()}" sig = f"{self.func.__name__}{self.sig}" - cause = str(self.__cause__) if self.__cause__ else "" + # remove the leading "_custom_bind_fn()" that comes from the custom bind function generated in SignatureBinder + cause = str(self.__cause__).removeprefix('_custom_bind_fn() ') if self.__cause__ else "" return self.msg.format(sig=sig, call=call, cause=cause, errors=errors) @@ -295,13 +296,83 @@ def from_argument(cls, name: str, annotation: Argument) -> Self: ) +class ReprableVariableName: + """Holds a string that will be used as a variable name in code to generate a default value for a parameter + in a binding function for a Signature created by SignatureBinder. + + Needed because Signature.__repr__, which is used to generate binding function argument list, will call repr() on default values. + """ + def __init__(self, name: str): + self.name = name + + def __repr__(self): + """Return the variable name without quotes.""" + return self.name + + +class SignatureBinder: + """Given a Signature, builds a callable object that binds arguments to parameters + according to that Signature, returning a dict of parameter names to bound values. + + Behaviour of the resulting callable object is equivalent to inspect.Signature.bind, + but is more performant as it uses cpython's argument binding logic directly, + instead of a slower pure-python implementation. + + Example:: + + from ibis.common.annotations import Signature + def fn(a, b: int, c: Foo = Foo()): ... + sig = Signature.from_callable(fn) + binder = SignatureBinder(sig) + binder(1, 2) # returns {'a': 1, 'b': 2, 'c': Foo()} + """ + + def __init__(self, signature: Signature): + namespace = {} # a namespace of default variable name -> default value used with exec below + processed_params = [] + for name, param in signature.parameters.items(): + if param.default is not inspect.Parameter.empty: + # Create a unique variable name for the default value of this parameter, + # and store the actual default value in the namespace under that name. + varname = f'__default_{name}__' + default_val = ReprableVariableName(varname) + namespace[varname] = param.default + else: + default_val = inspect.Parameter.empty + + processed_params.append(param.replace( + default=default_val, + annotation=inspect.Parameter.empty + )) + + # build a new signature with default values replaced with generated variable names + processed_signature = inspect.Signature(parameters=processed_params) + self.bind_fn_str = f'def _custom_bind_fn{processed_signature}:\n return locals()' + + exec(compile(self.bind_fn_str, '', 'exec'), namespace) + self._bind_fn = namespace['_custom_bind_fn'] + + def __call__(self, *args, **kwargs): + return self._bind_fn(*args, **kwargs) + + def __repr__(self) -> str: + """To help with debugging, returns the generated source code of the binding function.""" + return self.bind_fn_str + + class Signature(inspect.Signature): """Validatable signature. Primarily used in the implementation of `ibis.common.grounds.Annotable`. """ - __slots__ = () + __slots__ = ('_patterns', '_binder_fn') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # prebuild dict of patterns to avoid slow retrieval via property&MappingProxyType + self._patterns = {k: param.annotation.pattern for k, param in self.parameters.items() if hasattr(param.annotation, 'pattern')} + self._binder_fn = SignatureBinder(self)._bind_fn @classmethod def merge(cls, *signatures, **annotations): @@ -509,15 +580,27 @@ def validate(self, func, args, kwargs): return this + def validate_fast(self, func, args, kwargs): + """Faster validation using custom bind function for this signature (instead of Signature.bind).""" + try: + bound_kwargs = self._binder_fn(*args, **kwargs) + except TypeError as err: + raise SignatureValidationError( + "{call} {cause}\n\nExpected signature: {sig}", + sig=self, + func=func, + args=args, + kwargs=kwargs, + ) from err + + return self.validate_nobind(func, bound_kwargs) + def validate_nobind(self, func, kwargs): """Validate the arguments against the signature without binding.""" this, errors = {}, [] - for name, param in self.parameters.items(): - value = kwargs.get(name, param.default) - if value is EMPTY: - raise TypeError(f"missing required argument `{name!r}`") + for name, pattern in self._patterns.items(): + value = kwargs[name] - pattern = param.annotation.pattern result = pattern.match(value, this) if result is NoMatch: errors.append((name, value, pattern)) diff --git a/ibis/common/grounds.py b/ibis/common/grounds.py index bb4d3e3b4cf2..217ed9b6a09e 100644 --- a/ibis/common/grounds.py +++ b/ibis/common/grounds.py @@ -116,8 +116,8 @@ class Annotable(Abstract, metaclass=AnnotableMeta): @classmethod def __create__(cls, *args: Any, **kwargs: Any) -> Self: # construct the instance by passing only validated keyword arguments - kwargs = cls.__signature__.validate(cls, args, kwargs) - return super().__create__(**kwargs) + validated_kwargs = cls.__signature__.validate_fast(cls, args, kwargs) + return super().__create__(**validated_kwargs) @classmethod def __recreate__(cls, kwargs: Any) -> Self: diff --git a/ibis/common/tests/snapshots/test_grounds/test_error_message/error_message.txt b/ibis/common/tests/snapshots/test_grounds/test_error_message/error_message.txt index 4c6ac61845a2..7f34745cbdef 100644 --- a/ibis/common/tests/snapshots/test_grounds/test_error_message/error_message.txt +++ b/ibis/common/tests/snapshots/test_grounds/test_error_message/error_message.txt @@ -1,4 +1,4 @@ -Example('1', '2', '3', '4', '5', []) has failed due to the following errors: +Example(a='1', b='2', c='3', d='4', e='5', f=[]) has failed due to the following errors: `a`: '1' of type is not an int `b`: '2' of type is not an int `d`: '4' of type is not either None or a float diff --git a/ibis/common/tests/snapshots/test_grounds/test_error_message/error_message_py311.txt b/ibis/common/tests/snapshots/test_grounds/test_error_message/error_message_py311.txt index b09f86afa401..c34d2bd84c9f 100644 --- a/ibis/common/tests/snapshots/test_grounds/test_error_message/error_message_py311.txt +++ b/ibis/common/tests/snapshots/test_grounds/test_error_message/error_message_py311.txt @@ -1,4 +1,4 @@ -Example('1', '2', '3', '4', '5', []) has failed due to the following errors: +Example(a='1', b='2', c='3', d='4', e='5', f=[]) has failed due to the following errors: `a`: '1' of type is not an int `b`: '2' of type is not an int `d`: '4' of type is not either None or a float diff --git a/ibis/common/tests/test_grounds.py b/ibis/common/tests/test_grounds.py index ea189a8c38e4..47d2c330d9a0 100644 --- a/ibis/common/tests/test_grounds.py +++ b/ibis/common/tests/test_grounds.py @@ -546,7 +546,7 @@ class Test2(Test): c = is_int args = varargs(is_int) - with pytest.raises(ValidationError, match="missing a required argument: 'c'"): + with pytest.raises(ValidationError, match="missing 1 required positional argument: 'c'"): Test2(1, 2) a = Test2(1, 2, 3) @@ -578,7 +578,7 @@ class Test2(Test): c = is_int options = varkwargs(is_int) - with pytest.raises(ValidationError, match="missing a required argument: 'c'"): + with pytest.raises(ValidationError, match="missing 1 required positional argument: 'c'"): Test2(1, 2) a = Test2(1, 2, c=3) @@ -858,7 +858,7 @@ class Flexible(Annotable): def test_annotable_attribute(): - with pytest.raises(ValidationError, match="too many positional arguments"): + with pytest.raises(ValidationError, match="takes 1 positional argument but 2 were given"): BaseValue(1, 2) v = BaseValue(1) diff --git a/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call0-missing_a_required_argument/missing_a_required_argument.txt b/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call0-missing_a_required_argument/missing_a_required_argument.txt index 82b70db10e11..2310cc7edb6a 100644 --- a/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call0-missing_a_required_argument/missing_a_required_argument.txt +++ b/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call0-missing_a_required_argument/missing_a_required_argument.txt @@ -1,3 +1,3 @@ -Literal(1) missing a required argument: 'dtype' +Literal(1) missing 1 required positional argument: 'dtype' Expected signature: Literal(value: Annotated[Any, Not(pattern=InstanceOf(type=))], dtype: DataType) \ No newline at end of file diff --git a/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call1-too_many_positional_arguments/too_many_positional_arguments.txt b/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call1-too_many_positional_arguments/too_many_positional_arguments.txt index 5336bc197fbf..54941ef1a907 100644 --- a/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call1-too_many_positional_arguments/too_many_positional_arguments.txt +++ b/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call1-too_many_positional_arguments/too_many_positional_arguments.txt @@ -1,3 +1,3 @@ -Literal(1, Int8(nullable=True), 'foo') too many positional arguments +Literal(1, Int8(nullable=True), 'foo') takes 2 positional arguments but 3 were given Expected signature: Literal(value: Annotated[Any, Not(pattern=InstanceOf(type=))], dtype: DataType) \ No newline at end of file diff --git a/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call3-multiple_values_for_argument/multiple_values_for_argument.txt b/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call3-multiple_values_for_argument/multiple_values_for_argument.txt index c2f857abfdc1..eb1b42984d43 100644 --- a/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call3-multiple_values_for_argument/multiple_values_for_argument.txt +++ b/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call3-multiple_values_for_argument/multiple_values_for_argument.txt @@ -1,3 +1,3 @@ -Literal(1, Int8(nullable=True), dtype=Int16(nullable=True)) multiple values for argument 'dtype' +Literal(1, Int8(nullable=True), dtype=Int16(nullable=True)) got multiple values for argument 'dtype' Expected signature: Literal(value: Annotated[Any, Not(pattern=InstanceOf(type=))], dtype: DataType) \ No newline at end of file diff --git a/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call4-invalid_dtype/invalid_dtype.txt b/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call4-invalid_dtype/invalid_dtype.txt index e7a387d1763e..6f3417f6089c 100644 --- a/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call4-invalid_dtype/invalid_dtype.txt +++ b/ibis/expr/operations/tests/snapshots/test_generic/test_error_message_when_constructing_literal/call4-invalid_dtype/invalid_dtype.txt @@ -1,4 +1,4 @@ -Literal(1, 4) has failed due to the following errors: +Literal(value=1, dtype=4) has failed due to the following errors: `dtype`: 4 of type is not coercible to a DataType Expected signature: Literal(value: Annotated[Any, Not(pattern=InstanceOf(type=))], dtype: DataType) \ No newline at end of file