Repository: quora/pyanalyze
Pull request #385 (2022-01-01 12:28:15)

Minor ParamSpec improvements
On the way to supporting type eval function type compatibility.


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 48766bb..2e7ff21 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,7 @@
 
 ## Unreleased
 
+- Small improvements to `ParamSpec` support (#385)
 - Allow `CustomCheck` to customize what values
   a value can be assigned to (#383)
 - Fix incorrect inference of `self` argument on
diff --git a/pyanalyze/annotations.py b/pyanalyze/annotations.py
index fd2f6b4..d32b12f 100644
--- a/pyanalyze/annotations.py
+++ b/pyanalyze/annotations.py
@@ -59,7 +59,7 @@ from .extensions import (
     TypeGuard,
 )
 from .find_unused import used
-from .signature import SigParameter, Signature, ParameterKind
+from .signature import ELLIPSIS_PARAM, SigParameter, Signature, ParameterKind
 from .safe import is_typing_name, is_instance_of_typing_name
 from . import type_evaluation
 from .value import (
@@ -75,6 +75,8 @@ from .value import (
     MultiValuedValue,
     NO_RETURN_VALUE,
     NoReturnGuardExtension,
+    ParamSpecArgsValue,
+    ParamSpecKwargsValue,
     ParameterTypeGuardExtension,
     TypeGuardExtension,
     TypedValue,
@@ -391,7 +393,7 @@ def _type_from_runtime(val: Any, ctx: Context, is_typeddict: bool = False) -> Va
         args = typing_inspect.get_args(val)
         return _value_of_origin_args(Callable, args, val, ctx)
     elif val is AsynqCallable:
-        return CallableValue(Signature.make([], is_ellipsis_args=True, is_asynq=True))
+        return CallableValue(Signature.make([ELLIPSIS_PARAM], is_asynq=True))
     elif isinstance(val, type):
         return _maybe_typed_value(val)
     elif val is None:
@@ -425,6 +427,10 @@ def _type_from_runtime(val: Any, ctx: Context, is_typeddict: bool = False) -> Va
         return TypeVarValue(tv, bound=bound, constraints=constraints)
     elif is_instance_of_typing_name(val, "ParamSpec"):
         return TypeVarValue(val, is_paramspec=True)
+    elif is_instance_of_typing_name(val, "ParamSpecArgs"):
+        return ParamSpecArgsValue(val.__origin__)
+    elif is_instance_of_typing_name(val, "ParamSpecKwargs"):
+        return ParamSpecKwargsValue(val.__origin__)
     elif is_typing_name(val, "Final") or is_typing_name(val, "ClassVar"):
         return AnyValue(AnySource.incomplete_annotation)
     elif typing_inspect.is_classvar(val) or typing_inspect.is_final_type(val):
@@ -467,14 +473,9 @@ def _type_from_runtime(val: Any, ctx: Context, is_typeddict: bool = False) -> Va
             [TypeGuardExtension(_type_from_runtime(val.__type__, ctx))],
         )
     elif isinstance(val, AsynqCallable):
-        params, is_ellipsis_args = _callable_args_from_runtime(
-            val.args, "AsynqCallable", ctx
-        )
+        params = _callable_args_from_runtime(val.args, "AsynqCallable", ctx)
         sig = Signature.make(
-            params,
-            _type_from_runtime(val.return_type, ctx),
-            is_asynq=True,
-            is_ellipsis_args=is_ellipsis_args,
+            params, _type_from_runtime(val.return_type, ctx), is_asynq=True
         )
         return CallableValue(sig)
     elif isinstance(val, ExternalType):
@@ -506,14 +507,14 @@ def _type_from_runtime(val: Any, ctx: Context, is_typeddict: bool = False) -> Va
 
 def _callable_args_from_runtime(
     arg_types: Any, label: str, ctx: Context
-) -> Tuple[Sequence[SigParameter], bool]:
+) -> Sequence[SigParameter]:
     if arg_types is Ellipsis or arg_types == [Ellipsis]:
-        return [], True
+        return [ELLIPSIS_PARAM]
     elif type(arg_types) in (tuple, list):
         if len(arg_types) == 1:
             (arg,) = arg_types
             if arg is Ellipsis:
-                return [], True
+                return [ELLIPSIS_PARAM]
             elif is_typing_name(getattr(arg, "__origin__", None), "Concatenate"):
                 return _args_from_concatenate(arg, ctx)
             elif is_instance_of_typing_name(arg, "ParamSpec"):
@@ -521,7 +522,7 @@ def _callable_args_from_runtime(
                 param = SigParameter(
                     "__P", kind=ParameterKind.PARAM_SPEC, annotation=param_spec
                 )
-                return [param], False
+                return [param]
         types = [_type_from_runtime(arg, ctx) for arg in arg_types]
         params = [
             SigParameter(
@@ -533,23 +534,21 @@ def _callable_args_from_runtime(
             )
             for i, typ in enumerate(types)
         ]
-        return params, False
+        return params
     elif is_instance_of_typing_name(arg_types, "ParamSpec"):
         param_spec = TypeVarValue(arg_types, is_paramspec=True)
         param = SigParameter(
             "__P", kind=ParameterKind.PARAM_SPEC, annotation=param_spec
         )
-        return [param], False
+        return [param]
     elif is_typing_name(getattr(arg_types, "__origin__", None), "Concatenate"):
         return _args_from_concatenate(arg_types, ctx)
     else:
         ctx.show_error(f"Invalid arguments to {label}: {arg_types!r}")
-        return [], True
+        return [ELLIPSIS_PARAM]
 
 
-def _args_from_concatenate(
-    concatenate: Any, ctx: Context
-) -> Tuple[Sequence[SigParameter], bool]:
+def _args_from_concatenate(concatenate: Any, ctx: Context) -> Sequence[SigParameter]:
     types = [_type_from_runtime(arg, ctx) for arg in concatenate.__args__]
     params = [
         SigParameter(
@@ -561,7 +560,7 @@ def _args_from_concatenate(
         )
         for i, annotation in enumerate(types)
     ]
-    return params, False
+    return params
 
 
 def _get_typeddict_value(
@@ -987,14 +986,8 @@ def _value_of_origin_args(
         *arg_types, return_type = args
         if len(arg_types) == 1 and isinstance(arg_types[0], list):
             arg_types = arg_types[0]
-        params, is_ellipsis_args = _callable_args_from_runtime(
-            arg_types, "Callable", ctx
-        )
-        sig = Signature.make(
-            params,
-            _type_from_runtime(return_type, ctx),
-            is_ellipsis_args=is_ellipsis_args,
-        )
+        params = _callable_args_from_runtime(arg_types, "Callable", ctx)
+        sig = Signature.make(params, _type_from_runtime(return_type, ctx))
         return CallableValue(sig)
     elif is_typing_name(origin, "Annotated"):
         origin, metadata = args
@@ -1077,10 +1070,7 @@ def _make_callable_from_value(
     if args == KnownValue(Ellipsis):
         return CallableValue(
             Signature.make(
-                [],
-                return_annotation=return_annotation,
-                is_ellipsis_args=True,
-                is_asynq=is_asynq,
+                [ELLIPSIS_PARAM], return_annotation=return_annotation, is_asynq=is_asynq
             )
         )
     elif isinstance(args, SequenceIncompleteValue):
diff --git a/pyanalyze/arg_spec.py b/pyanalyze/arg_spec.py
index 0eecdc9..502dabd 100644
--- a/pyanalyze/arg_spec.py
+++ b/pyanalyze/arg_spec.py
@@ -22,6 +22,7 @@ from .safe import (
 from .stacked_scopes import Composite, uniq_chain
 from .signature import (
     ANY_SIGNATURE,
+    ELLIPSIS_PARAM,
     ConcreteSignature,
     Impl,
     MaybeSignature,
@@ -642,9 +643,8 @@ class ArgSpecCache:
                     inspect_sig = self._safe_get_signature(constructor)
                 if inspect_sig is None:
                     return Signature.make(
-                        [],
+                        [ELLIPSIS_PARAM],
                         return_type,
-                        is_ellipsis_args=True,
                         callable=obj,
                         allow_call=allow_call,
                     )
@@ -703,9 +703,8 @@ class ArgSpecCache:
     def _make_any_sig(self, obj: object) -> Signature:
         if FunctionsSafeToCall.contains(obj, self.options):
             return Signature.make(
-                [],
+                [ELLIPSIS_PARAM],
                 AnyValue(AnySource.inference),
-                is_ellipsis_args=True,
                 is_asynq=True,
                 allow_call=True,
                 callable=obj,
diff --git a/pyanalyze/signature.py b/pyanalyze/signature.py
index 5be4221..0337d9f 100644
--- a/pyanalyze/signature.py
+++ b/pyanalyze/signature.py
@@ -37,6 +37,8 @@ from .value import (
     MultiValuedValue,
     NoReturnConstraintExtension,
     NoReturnGuardExtension,
+    ParamSpecArgsValue,
+    ParamSpecKwargsValue,
     ParameterTypeGuardExtension,
     SequenceIncompleteValue,
     DictIncompleteValue,
@@ -95,6 +97,7 @@ if TYPE_CHECKING:
 
 EMPTY = inspect.Parameter.empty
 UNANNOTATED = AnyValue(AnySource.unannotated)
+ELLIPSIS_COMPOSITE = Composite(AnyValue(AnySource.ellipsis_callable))
 
 
 @dataclass
@@ -131,6 +134,8 @@ class ActualArguments:
     keywords: Dict[str, Tuple[bool, Composite]]
     star_kwargs: Optional[Value]  # represents the type of the elements of **kwargs
     kwargs_required: bool
+    ellipsis: bool = False
+    param_spec: Optional[TypeVarValue] = None
 
 
 class CallReturn(NamedTuple):
@@ -252,6 +257,10 @@ class ParameterKind(enum.Enum):
     KEYWORD_ONLY = 3
     VAR_KEYWORD = 4
     PARAM_SPEC = 5
+    ELLIPSIS = 6  # Callable[..., T]
+    """Special kind for `Callable[..., T]`. Such callables are compatible
+    with any other callable. There can only be one ELLIPSIS parameter
+    and it must be the last one."""
 
 
 @dataclass
@@ -263,7 +272,7 @@ class SigParameter:
     kind: ParameterKind = ParameterKind.POSITIONAL_OR_KEYWORD
     """How the parameter can be passed."""
     default: Optional[Value] = None
-    """The default for the parameter, or EMPTY if there is no default."""
+    """The default for the parameter, or None if there is no default."""
     annotation: Value = AnyValue(AnySource.unannotated)
     """Type annotation for the parameter."""
 
@@ -345,9 +354,6 @@ class Signature:
     is_asynq: bool = False
     """Whether this signature represents an asynq function."""
     has_return_annotation: bool = True
-    is_ellipsis_args: bool = False
-    """Whether this signature represents a ``Callable[..., T]`` callable. Such
-    a callable is compatible with any other callable with a compatible return type."""
     allow_call: bool = False
     """Whether type checking can call the actual function to retrieve a precise return value."""
     evaluator: Optional[Evaluator] = None
@@ -533,6 +539,7 @@ class Signature:
         bound_args: BoundArgs = {}
         star_args_consumed = False
         star_kwargs_consumed = False
+        param_spec_consumed = False
 
         for param in self.parameters.values():
             if param.kind is ParameterKind.POSITIONAL_ONLY:
@@ -549,15 +556,17 @@ class Signature:
                         position = UNKNOWN  # default or args
                     bound_args[param.name] = position, Composite(actual_args.star_args)
                     star_args_consumed = True
-                elif param.default is None:
+                elif param.default is not None:
+                    bound_args[param.name] = DEFAULT, Composite(param.default)
+                elif actual_args.ellipsis:
+                    bound_args[param.name] = UNKNOWN, ELLIPSIS_COMPOSITE
+                else:
                     self.show_call_error(
                         f"Missing required positional argument: '{param.name}'",
                         node,
                         visitor,
                     )
                     return None
-                else:
-                    bound_args[param.name] = DEFAULT, Composite(param.default)
             elif param.kind is ParameterKind.POSITIONAL_OR_KEYWORD:
                 if positional_index < len(actual_args.positionals):
                     bound_args[param.name] = (
@@ -599,7 +608,11 @@ class Signature:
                     bound_args[param.name] = position, Composite(value)
                 elif param.name in actual_args.keywords:
                     definitely_provided, composite = actual_args.keywords[param.name]
-                    if not definitely_provided and param.default is None:
+                    if (
+                        not definitely_provided
+                        and param.default is None
+                        and not actual_args.ellipsis
+                    ):
                         self.show_call_error(
                             f"Parameter '{param.name}' may not be provided by this"
                             " call",
@@ -618,17 +631,23 @@ class Signature:
                         actual_args.star_kwargs
                     )
                     star_kwargs_consumed = True
-                elif param.default is None:
+                elif param.default is not None:
+                    bound_args[param.name] = DEFAULT, Composite(param.default)
+                elif actual_args.ellipsis:
+                    bound_args[param.name] = DEFAULT, ELLIPSIS_COMPOSITE
+                else:
                     self.show_call_error(
                         f"Missing required argument: '{param.name}'", node, visitor
                     )
                     return None
-                else:
-                    bound_args[param.name] = DEFAULT, Composite(param.default)
             elif param.kind is ParameterKind.KEYWORD_ONLY:
                 if param.name in actual_args.keywords:
                     definitely_provided, composite = actual_args.keywords[param.name]
-                    if not definitely_provided and param.default is None:
+                    if (
+                        not definitely_provided
+                        and param.default is None
+                        and not actual_args.ellipsis
+                    ):
                         self.show_call_error(
                             f"Parameter '{param.name}' may not be provided by this"
                             " call",
@@ -648,13 +667,15 @@ class Signature:
                     )
                     star_kwargs_consumed = True
                     keywords_consumed.add(param.name)
-                elif param.default is None:
+                elif param.default is not None:
+                    bound_args[param.name] = DEFAULT, Composite(param.default)
+                elif actual_args.ellipsis:
+                    bound_args[param.name] = DEFAULT, ELLIPSIS_COMPOSITE
+                else:
                     self.show_call_error(
                         f"Missing required argument: '{param.name}'", node, visitor
                     )
                     return None
-                else:
-                    bound_args[param.name] = DEFAULT, Composite(param.default)
             elif param.kind is ParameterKind.VAR_POSITIONAL:
                 star_args_consumed = True
                 positionals = []
@@ -662,7 +683,11 @@ class Signature:
                     positionals.append(actual_args.positionals[positional_index].value)
                     positional_index += 1
                 position = ARGS
-                if actual_args.star_args is not None:
+                if actual_args.ellipsis:
+                    star_args_value = GenericValue(
+                        tuple, [AnyValue(AnySource.ellipsis_callable)]
+                    )
+                elif actual_args.star_args is not None:
                     element_value = unite_values(*positionals, actual_args.star_args)
                     star_args_value = GenericValue(tuple, [element_value])
                 else:
@@ -682,7 +707,11 @@ class Signature:
                         continue
                     items[key] = (definitely_provided, composite.value)
                 position = KWARGS
-                if actual_args.star_kwargs is not None:
+                if actual_args.ellipsis:
+                    star_kwargs_value = GenericValue(
+                        dict, [TypedValue(str), AnyValue(AnySource.ellipsis_callable)]
+                    )
+                elif actual_args.star_kwargs is not None:
                     value_value = unite_values(
                         *(val for _, val in items.values()), actual_args.star_kwargs
                     )
@@ -694,6 +723,41 @@ class Signature:
                     if not items:
                         position = DEFAULT
                 bound_args[param.name] = position, Composite(star_kwargs_value)
+            elif param.kind is ParameterKind.ELLIPSIS:
+                # just take it all
+                star_args_consumed = True
+                star_kwargs_consumed = True
+                param_spec_consumed = True
+                bound_args[param.name] = UNKNOWN, Composite(
+                    AnyValue(AnySource.ellipsis_callable)
+                )
+            elif param.kind is ParameterKind.PARAM_SPEC:
+                if actual_args.param_spec is not None:
+                    bound_args[param.name] = KWARGS, Composite(actual_args.param_spec)
+                    param_spec_consumed = True
+                elif (
+                    actual_args.star_args is not None
+                    and actual_args.star_kwargs is not None
+                    and not star_args_consumed
+                    and not star_kwargs_consumed
+                    and isinstance(actual_args.star_args, ParamSpecArgsValue)
+                    and isinstance(actual_args.star_kwargs, ParamSpecKwargsValue)
+                    and actual_args.star_args.param_spec
+                    is actual_args.star_kwargs.param_spec
+                ):
+                    star_kwargs_consumed = True
+                    star_args_consumed = True
+                    composite = Composite(
+                        TypeVarValue(
+                            actual_args.star_kwargs.param_spec, is_paramspec=True
+                        )
+                    )
+                    bound_args[param.name] = KWARGS, composite
+                else:
+                    self.show_call_error(
+                        "Callable requires a ParamSpec argument", node, visitor
+                    )
+                    return None
             else:
                 assert False, f"unhandled param {param.kind}"
 
@@ -725,6 +789,8 @@ class Signature:
         ):
             self.show_call_error("**kwargs provided but not used", node, visitor)
             return None
+        if not param_spec_consumed and actual_args.param_spec is not None:
+            self.show_call_error("ParamSpec provided but not used", node, visitor)
         return bound_args
 
     def show_call_error(
@@ -767,13 +833,6 @@ class Signature:
         node: ast.AST,
         is_overload: bool = False,
     ) -> CallReturn:
-        if self.is_ellipsis_args:
-            if self.allow_call:
-                runtime_return = self._maybe_perform_call(preprocessed, visitor, node)
-                if runtime_return is not None:
-                    return CallReturn(runtime_return)
-            return CallReturn(self.return_value, used_any_for_match=True)
-
         bound_args = self.bind_arguments(preprocessed, visitor, node)
         if bound_args is None:
             return self.get_default_return()
@@ -981,8 +1040,6 @@ class Signature:
             return CanAssignError(
                 "return annotation is not compatible", [return_tv_map]
             )
-        if self.is_ellipsis_args or other.is_ellipsis_args:
-            return return_tv_map
         tv_maps = [return_tv_map]
         their_params = list(other.parameters.values())
         their_args = other.get_param_of_kind(ParameterKind.VAR_POSITIONAL)
@@ -997,6 +1054,9 @@ class Signature:
             kwargs_annotation = their_kwargs.get_annotation()
         else:
             kwargs_annotation = None
+        their_ellipsis = other.get_param_of_kind(ParameterKind.ELLIPSIS)
+        if their_ellipsis is not None:
+            args_annotation = kwargs_annotation = AnyValue(AnySource.ellipsis_callable)
         consumed_positional = set()
         consumed_keyword = set()
         consumed_paramspec = False
@@ -1170,6 +1230,8 @@ class Signature:
                 assert isinstance(my_annotation, TypeVarValue)
                 tv_maps.append({my_annotation.typevar: CallableValue(new_sig)})
                 consumed_paramspec = True
+            elif my_param.kind is ParameterKind.ELLIPSIS:
+                consumed_paramspec = True
             else:
                 assert False, f"unhandled param {my_param}"
 
@@ -1198,6 +1260,8 @@ class Signature:
                         return CanAssignError(f"takes extra parameter {param.name!r}")
                 elif param.kind is ParameterKind.PARAM_SPEC:
                     return CanAssignError(f"takes extra ParamSpec {param!r}")
+                elif param.kind is ParameterKind.ELLIPSIS:
+                    continue
                 else:
                     assert False, f"unhandled param {param}"
 
@@ -1211,7 +1275,6 @@ class Signature:
 
     def substitute_typevars(self, typevars: TypeVarMap) -> "Signature":
         params = []
-        is_ellipsis_args = self.is_ellipsis_args
         for name, param in self.parameters.items():
             if param.kind is ParameterKind.PARAM_SPEC:
                 assert isinstance(param.annotation, TypeVarValue)
@@ -1225,9 +1288,8 @@ class Signature:
                         )
                         params.append((name, new_param))
                     elif isinstance(new_val, AnyValue):
-                        is_ellipsis_args = True
-                        params = []
-                        break
+                        new_param = SigParameter(param.name, ParameterKind.ELLIPSIS)
+                        params.append((param.name, new_param))
                     else:
                         assert isinstance(new_val, CallableValue), new_val
                         assert isinstance(new_val.signature, Signature), new_val
@@ -1243,7 +1305,6 @@ class Signature:
             callable=self.callable,
             is_asynq=self.is_asynq,
             has_return_annotation=self.has_return_annotation,
-            is_ellipsis_args=is_ellipsis_args,
             allow_call=self.allow_call,
             evaluator=self.evaluator,
         )
@@ -1265,7 +1326,6 @@ class Signature:
             impl=self.impl,
             callable=self.callable,
             has_return_annotation=self.has_return_annotation,
-            is_ellipsis_args=self.is_ellipsis_args,
             is_asynq=False,
             allow_call=self.allow_call,
             evaluator=self.evaluator,
@@ -1280,7 +1340,6 @@ class Signature:
         impl: Optional[Impl] = None,
         callable: Optional[object] = None,
         has_return_annotation: bool = True,
-        is_ellipsis_args: bool = False,
         is_asynq: bool = False,
         allow_call: bool = False,
         evaluator: Optional[Evaluator] = None,
@@ -1301,7 +1360,6 @@ class Signature:
             impl=impl,
             callable=callable,
             has_return_annotation=has_return_annotation,
-            is_ellipsis_args=is_ellipsis_args,
             is_asynq=is_asynq,
             allow_call=allow_call,
             evaluator=evaluator,
@@ -1318,10 +1376,7 @@ class Signature:
         return rendered
 
     def _render_parameters(self) -> Iterable[str]:
-        # Adapted from Signature's own __str__
-        if self.is_ellipsis_args:
-            yield "..."
-            return
+        # Adapted from inspect.Signature's __str__
         render_pos_only_separator = False
         render_kw_only_separator = True
         for param in self.parameters.values():
@@ -1347,16 +1402,21 @@ class Signature:
             yield "/"
 
     def bind_self(self, *, preserve_impl: bool = False) -> Optional["Signature"]:
-        if self.is_ellipsis_args:
-            return ANY_SIGNATURE
         params = list(self.parameters.values())
-        if not params or params[0].kind not in (
+        if not params:
+            return None
+        kind = params[0].kind
+        if kind is ParameterKind.ELLIPSIS:
+            new_params = {param.name: param for param in params}
+        elif kind in (
             ParameterKind.POSITIONAL_ONLY,
             ParameterKind.POSITIONAL_OR_KEYWORD,
         ):
+            new_params = {param.name: param for param in params[1:]}
+        else:
             return None
         return Signature(
-            {param.name: param for param in params[1:]},
+            new_params,
             self.return_value,
             # We don't carry over the implementation function by default, because it
             # may not work when passed different arguments.
@@ -1364,7 +1424,6 @@ class Signature:
             callable=self.callable,
             is_asynq=self.is_asynq,
             has_return_annotation=self.has_return_value(),
-            is_ellipsis_args=self.is_ellipsis_args,
             allow_call=self.allow_call,
         )
 
@@ -1372,8 +1431,9 @@ class Signature:
         return self.has_return_annotation or self.evaluator is not None
 
 
+ELLIPSIS_PARAM = SigParameter("...", ParameterKind.ELLIPSIS)
 ANY_SIGNATURE = Signature.make(
-    [], AnyValue(AnySource.explicit), is_ellipsis_args=True, is_asynq=True
+    [ELLIPSIS_PARAM], AnyValue(AnySource.explicit), is_asynq=True
 )
 """:class:`Signature` that should be compatible with any other
 :class:`Signature`."""
diff --git a/pyanalyze/value.py b/pyanalyze/value.py
index d598d44..686cc3b 100644
--- a/pyanalyze/value.py
+++ b/pyanalyze/value.py
@@ -340,6 +340,8 @@ class AnySource(enum.Enum):
     """A special form like ClassVar without a type argument."""
     multiple_overload_matches = 12
     """Multiple matching overloads."""
+    ellipsis_callable = 13
+    """Callable using an ellipsis."""
 
 
 @dataclass(frozen=True)
@@ -1555,6 +1557,22 @@ class TypeVarValue(Value):
         return str(self.typevar)
 
 
+@dataclass
+class ParamSpecArgsValue(Value):
+    param_spec: ParamSpec
+
+    def __str__(self) -> str:
+        return f"{self.param_spec}.args"
+
+
+@dataclass
+class ParamSpecKwargsValue(Value):
+    param_spec: ParamSpec
+
+    def __str__(self) -> str:
+        return f"{self.param_spec}.kwargs"
+
+
 class Extension:
     """An extra piece of information about a type that can be stored in
     an :class:`AnnotatedValue`."""


Test patch:
diff --git a/pyanalyze/test_annotations.py b/pyanalyze/test_annotations.py
index 24df5e8..f6dd769 100644
--- a/pyanalyze/test_annotations.py
+++ b/pyanalyze/test_annotations.py
@@ -824,14 +824,14 @@ class TestCallable(TestNameCheckVisitorBase):
     def test_asynq_callable(self):
         from asynq import asynq
         from pyanalyze.extensions import AsynqCallable
-        from pyanalyze.signature import Signature
+        from pyanalyze.signature import Signature, ELLIPSIS_PARAM
         from typing import Optional
 
         @asynq()
         def func_example(x: int) -> str:
             return ""
 
-        sig = Signature.make([], is_asynq=True, is_ellipsis_args=True)
+        sig = Signature.make([ELLIPSIS_PARAM], is_asynq=True)
 
         @asynq()
         def bare_asynq_callable(fn: AsynqCallable) -> None:
@@ -1651,3 +1651,28 @@ class TestCallable(TestNameCheckVisitorBase):
             )
             want_callable(1)  # E: incompatible_argument
             want_callable(int(unannotated))  # E: incompatible_argument
+
+    @assert_passes()
+    def test_args_kwargs(self):
+        from typing import Callable, TypeVar
+        from typing_extensions import Concatenate, ParamSpec
+
+        P = ParamSpec("P")
+        R = TypeVar("R")
+
+        class Request:
+            pass
+
+        def with_request(f: Callable[Concatenate[Request, P], R]) -> Callable[P, R]:
+            def inner(*args: P.args, **kwargs: P.kwargs) -> R:
+                return f(Request(), *args, **kwargs)
+
+            return inner
+
+        def takes_int_str(request: Request, x: int, y: str) -> int:
+            return x + 7
+
+        def capybara():
+            func = with_request(takes_int_str)
+            func(1, "A")
+            func(1, 2)  # E: incompatible_argument
diff --git a/pyanalyze/test_signature.py b/pyanalyze/test_signature.py
index d9454f2..087c823 100644
--- a/pyanalyze/test_signature.py
+++ b/pyanalyze/test_signature.py
@@ -18,6 +18,7 @@ from .test_name_check_visitor import TestNameCheckVisitorBase
 from .test_node_visitor import assert_fails, assert_passes, skip_before
 from .error_code import ErrorCode
 from .signature import (
+    ELLIPSIS_PARAM,
     ConcreteSignature,
     OverloadedSignature,
     Signature,
@@ -37,9 +38,7 @@ def test_stringify() -> None:
     assert "() -> Any[unannotated]" == str(Signature.make([]))
     assert "() -> int" == str(Signature.make([], TypedValue(int)))
     assert "@asynq () -> int" == str(Signature.make([], TypedValue(int), is_asynq=True))
-    assert "(...) -> int" == str(
-        Signature.make([], TypedValue(int), is_ellipsis_args=True)
-    )
+    assert "(...) -> int" == str(Signature.make([ELLIPSIS_PARAM], TypedValue(int)))
     assert "(x: int) -> int" == str(
         Signature.make([P("x", annotation=TypedValue(int))], TypedValue(int))
     )
diff --git a/pyanalyze/test_type_object.py b/pyanalyze/test_type_object.py
index be927de..1e25757 100644
--- a/pyanalyze/test_type_object.py
+++ b/pyanalyze/test_type_object.py
@@ -18,11 +18,9 @@ class TestSyntheticType(TestNameCheckVisitorBase):
     def test_functools(self):
         import functools
         import types
-        from pyanalyze.signature import Signature
+        from pyanalyze.signature import Signature, ELLIPSIS_PARAM
 
-        sig = Signature.make(
-            [], is_ellipsis_args=True, return_annotation=TypedValue(int)
-        )
+        sig = Signature.make([ELLIPSIS_PARAM], return_annotation=TypedValue(int))
 
         def f() -> int:
             return 0
diff --git a/pyanalyze/test_value.py b/pyanalyze/test_value.py
index c5fe638..71e6b01 100644
--- a/pyanalyze/test_value.py
+++ b/pyanalyze/test_value.py
@@ -14,7 +14,7 @@ from . import tests
 from . import value
 from .checker import Checker
 from .name_check_visitor import NameCheckVisitor
-from .signature import Signature
+from .signature import ELLIPSIS_PARAM, Signature
 from .stacked_scopes import Composite
 from .test_config import TestConfig
 from .value import (
@@ -112,7 +112,7 @@ def test_unbound_method_value() -> None:
     assert val.get_signature(CTX) is not None
     assert_can_assign(val, val)
     assert_cannot_assign(val, KnownValue(1))
-    assert_can_assign(val, CallableValue(Signature.make([], is_ellipsis_args=True)))
+    assert_can_assign(val, CallableValue(Signature.make([ELLIPSIS_PARAM])))
     assert_can_assign(val, CallableValue(Signature.make([])))