Repository: python/mypy

Issue:
Stubtest: option to ignore "runtime does not have argument" when implementation accepts *args/**kwargs
**Feature**

Pandas uses many decorators. Some of them change the function signature, e.g., `@deprecate_nonkeyword_arguments(...)` exposes a function that accepts `*args, **kwargs` instead of the original function. It would be nice to have an option to suppress "runtime does not have argument"-warnings when the implementation accepts *args/**kwargs.

**Pitch**

Add an option to deal with decorators or args/kwargs.

@hauntsaninja



Discussion:
I assume you're referring to errors like the following?
```
pandas.DataFrame.ffill is inconsistent, runtime argument "inplace" is not keyword-only
pandas.DataFrame.ffill is inconsistent, runtime argument "limit" is not keyword-only
pandas.DataFrame.ffill is inconsistent, runtime argument "downcast" is not keyword-only
pandas.DataFrame.ffill is inconsistent, runtime does not have argument "downcast"
pandas.DataFrame.ffill is inconsistent, runtime does not have argument "inplace"
pandas.DataFrame.ffill is inconsistent, runtime does not have argument "limit"
```

stubtest does actually handle decorators and args / kwargs mostly well. The issue is that `pandas.DataFrame.ffill` does not look like it takes `*args, **kwargs`. Because `deprecate_nonkeyword_arguments` uses `functools.wraps`, `inspect.signature` sees a more complete signature:
```
In [4]: inspect.signature(pandas.DataFrame.ffill)
Out[4]: <Signature (self: 'DataFrame', axis: 'None | Axis' = None, inplace: 'bool' = False, limit: 'None | int' = None, downcast=None) -> 'DataFrame | None'>
```

stubtest's output^ is confusing, but it basically is reporting errors twice, once when checking whether positional args match and once when checking whether keyword-only args match:
```
pandas.DataFrame.ffill is inconsistent, runtime argument "downcast" is not keyword-only
pandas.DataFrame.ffill is inconsistent, runtime does not have [keyword-only] argument "downcast"
```
If I were to fix the duplication, you'd still get the following (because `inspect.signature` sees the full signature because of `functools.wraps`):
```
pandas.DataFrame.ffill is inconsistent, runtime argument "inplace" is not keyword-only
pandas.DataFrame.ffill is inconsistent, runtime argument "limit" is not keyword-only
pandas.DataFrame.ffill is inconsistent, runtime argument "downcast" is not keyword-only
```
Thank you, that makes sense that it errors because of the arguments not being keyword-only! I focused only on the many "runtime does not have argument" warnings which did not seem very intuitive.

Resolving patch:
diff --git a/mypy/stubtest.py b/mypy/stubtest.py
--- a/mypy/stubtest.py
+++ b/mypy/stubtest.py
@@ -717,6 +717,7 @@ def _verify_signature(
         yield from _verify_arg_default_value(stub_arg, runtime_arg)
         if (
             runtime_arg.kind == inspect.Parameter.POSITIONAL_ONLY
+            and not stub_arg.pos_only
             and not stub_arg.variable.name.startswith("__")
             and not stub_arg.variable.name.strip("_") == "self"
             and not is_dunder(function_name, exclude_special=True)  # noisy for dunder methods
@@ -729,7 +730,7 @@ def _verify_signature(
             )
         if (
             runtime_arg.kind != inspect.Parameter.POSITIONAL_ONLY
-            and stub_arg.variable.name.startswith("__")
+            and (stub_arg.pos_only or stub_arg.variable.name.startswith("__"))
             and not is_dunder(function_name, exclude_special=True)  # noisy for dunder methods
         ):
             yield (
@@ -740,9 +741,9 @@ def _verify_signature(
     # Check unmatched positional args
     if len(stub.pos) > len(runtime.pos):
         # There are cases where the stub exhaustively lists out the extra parameters the function
-        # would take through *args. Hence, a) we can't check that the runtime actually takes those
-        # parameters and b) below, we don't enforce that the stub takes *args, since runtime logic
-        # may prevent those arguments from actually being accepted.
+        # would take through *args. Hence, a) if runtime accepts *args, we don't check whether the
+        # runtime has all of the stub's parameters, b) below, we don't enforce that the stub takes
+        # *args, since runtime logic may prevent arbitrary arguments from actually being accepted.
         if runtime.varpos is None:
             for stub_arg in stub.pos[len(runtime.pos) :]:
                 # If the variable is in runtime.kwonly, it's just mislabelled as not a
@@ -776,16 +777,24 @@ def _verify_signature(
     # Check unmatched keyword-only args
     if runtime.varkw is None or not set(runtime.kwonly).issubset(set(stub.kwonly)):
         # There are cases where the stub exhaustively lists out the extra parameters the function
-        # would take through *kwargs. Hence, a) we only check if the runtime actually takes those
-        # parameters when the above condition holds and b) below, we don't enforce that the stub
-        # takes *kwargs, since runtime logic may prevent additional arguments from actually being
-        # accepted.
+        # would take through **kwargs. Hence, a) if runtime accepts **kwargs (and the stub hasn't
+        # exhaustively listed out params), we don't check whether the runtime has all of the stub's
+        # parameters, b) below, we don't enforce that the stub takes **kwargs, since runtime logic
+        # may prevent arbitrary keyword arguments from actually being accepted.
         for arg in sorted(set(stub.kwonly) - set(runtime.kwonly)):
-            yield f'runtime does not have argument "{arg}"'
+            if arg in {runtime_arg.name for runtime_arg in runtime.pos}:
+                # Don't report this if we've reported it before
+                if arg not in {runtime_arg.name for runtime_arg in runtime.pos[len(stub.pos) :]}:
+                    yield f'runtime argument "{arg}" is not keyword-only'
+            else:
+                yield f'runtime does not have argument "{arg}"'
     for arg in sorted(set(runtime.kwonly) - set(stub.kwonly)):
         if arg in {stub_arg.variable.name for stub_arg in stub.pos}:
             # Don't report this if we've reported it before
-            if len(stub.pos) > len(runtime.pos) and runtime.varpos is not None:
+            if not (
+                runtime.varpos is None
+                and arg in {stub_arg.variable.name for stub_arg in stub.pos[len(runtime.pos) :]}
+            ):
                 yield f'stub argument "{arg}" is not keyword-only'
         else:
             yield f'stub does not have argument "{arg}"'


Test patch:
diff --git a/mypy/test/teststubtest.py b/mypy/test/teststubtest.py
--- a/mypy/test/teststubtest.py
+++ b/mypy/test/teststubtest.py
@@ -288,6 +288,11 @@ def test_arg_kind(self) -> Iterator[Case]:
                 runtime="def runtime_posonly(number, /, text): pass",
                 error="runtime_posonly",
             )
+            yield Case(
+                stub="def stub_posonly_570(number: int, /, text: str) -> None: ...",
+                runtime="def stub_posonly_570(number, text): pass",
+                error="stub_posonly_570",
+            )
 
     @collect_cases
     def test_default_value(self) -> Iterator[Case]: