Repository: quora/pyanalyze
Pull request #601 (2023-03-16 00:33:59)

Fix PEP 570 pos-only args in stubs


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 8abcb06..116ba0f 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,6 +6,7 @@
 
 ## Unreleased
 
+- Fix handling of positional-only arguments using `/` syntax in stubs (#601)
 - Fix bug where objects with a `__call__` method that takes `*args` instead
   of `self` was not considered callable (#600)
 
diff --git a/pyanalyze/typeshed.py b/pyanalyze/typeshed.py
index f58395c..24734ad 100644
--- a/pyanalyze/typeshed.py
+++ b/pyanalyze/typeshed.py
@@ -840,13 +840,34 @@ class TypeshedFinder:
         if node.decorator_list:
             objclass = None
         args = node.args
-
-        num_without_defaults = len(args.args) - len(args.defaults)
-        defaults = [None] * num_without_defaults + args.defaults
-        arguments = list(
-            self._parse_param_list(
-                args.args, defaults, mod, ParameterKind.POSITIONAL_OR_KEYWORD, objclass
+        arguments: List[SigParameter] = []
+        if sys.version_info >= (3, 8):
+            assert hasattr(args, "posonlyargs")
+            num_pos_only_args = len(args.posonlyargs)
+            defaults = args.defaults
+            num_pos_only_defaults = len(defaults) - len(args.args)
+            if num_pos_only_defaults > 0:
+                num_without_default = num_pos_only_args - num_pos_only_defaults
+                pos_only_defaults = [None] * num_without_default + defaults[
+                    num_pos_only_defaults:
+                ]
+                defaults = defaults[num_pos_only_defaults:]
+            else:
+                pos_only_defaults = [None for _ in args.posonlyargs]
+            arguments += self._parse_param_list(
+                args.posonlyargs,
+                pos_only_defaults,
+                mod,
+                ParameterKind.POSITIONAL_ONLY,
+                objclass,
             )
+        else:
+            defaults = args.defaults
+
+        num_without_defaults = len(args.args) - len(defaults)
+        defaults = [None] * num_without_defaults + defaults
+        arguments += self._parse_param_list(
+            args.args, defaults, mod, ParameterKind.POSITIONAL_OR_KEYWORD, objclass
         )
         if autobind:
             if is_classmethod or not is_staticmethod:


Test patch:
diff --git a/pyanalyze/stubs/_pyanalyze_tests-stubs/posonly.pyi b/pyanalyze/stubs/_pyanalyze_tests-stubs/posonly.pyi
new file mode 100644
index 0000000..9359755
--- /dev/null
+++ b/pyanalyze/stubs/_pyanalyze_tests-stubs/posonly.pyi
@@ -0,0 +1,3 @@
+def f(x: int, /, y: int, z: int = ...) -> None: ...
+def g(x: int = ..., /, y: int = ...) -> None: ...
+def h(x: int, /, y: int = ..., z: int = ...) -> None: ...
diff --git a/pyanalyze/test_typeshed.py b/pyanalyze/test_typeshed.py
index ba0c7de..81f1882 100644
--- a/pyanalyze/test_typeshed.py
+++ b/pyanalyze/test_typeshed.py
@@ -208,6 +208,30 @@ class TestBundledStubs(TestNameCheckVisitorBase):
         assert isinstance(val, CallableValue)
         assert isinstance(val.signature, OverloadedSignature)
 
+    @skip_before((3, 8))
+    @assert_passes()
+    def test_pos_only(self):
+        def capybara():
+            from _pyanalyze_tests.posonly import f, g, h
+
+            f(1, 2)
+            f(1, 2, 3)
+            f(1, y=2, z=3)
+            f(x=1, y=2)  # E: incompatible_call
+            f()  # E: incompatible_call
+
+            g()
+            g(1)
+            g(1, 2)
+            g(1, y=2)
+            g(x=1)  # E: incompatible_call
+
+            h()  # E: incompatible_call
+            h(1)
+            h(x=1)  # E: incompatible_call
+            h(1, y=2)
+            h(1, 2, 3)
+
     def test_typeddict(self):
         tsf = TypeshedFinder.make(Checker(), TEST_OPTIONS, verbose=True)
         mod = "_pyanalyze_tests.typeddict"