Repository: quora/pyanalyze (python)

Issue:
pyanalyze doesn't support defining TypeVar under `if TYPE_CHECKING`
```
from typing import TYPE_CHECKING, TypeVar

if TYPE_CHECKING:
    _TypeT = TypeVar("_TypeT", bound=type)

    def c(cls: _TypeT) -> None:
        ...
```
gives:
```
Unrecognized annotation typing.TypeVar (code: invalid_annotation)
In b.py at line 6
   3: if TYPE_CHECKING:
   4:     _TypeT = TypeVar("_TypeT", bound=type)
   5: 
   6:     def c(cls: _TypeT) -> None:
                     ^
   7:         ...
```

Pull request:
Fix creating TypeVar and others in non-executed code
Fixes #744

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index e8ed75b..03e9783 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,10 @@
 
 ## Unreleased
 
+- Support calls to `TypeVar` and several other typing constructs in
+  code that is not executed (e.g., under `if TYPE_CHECKING`) (#746)
+- Fix spurious errors for the class-based syntax for creating
+  `NamedTuple` classes (#746)
 - Make error registry into a custom class instead of an enum, removing
   dependency on `aenum` (#739)
 - Treat subclasses of `int` as subclasses of `float` and `complex` too (#738)
diff --git a/pyanalyze/arg_spec.py b/pyanalyze/arg_spec.py
index c654429..12b0ef2 100644
--- a/pyanalyze/arg_spec.py
+++ b/pyanalyze/arg_spec.py
@@ -6,6 +6,7 @@ Implementation of extended argument specifications used by test_scope.
 
 import ast
 import asyncio
+import collections
 import contextlib
 import enum
 import inspect
@@ -34,6 +35,7 @@ from unittest import mock
 
 import asynq
 import qcore
+import typing_extensions
 from typing_extensions import is_typeddict
 
 import pyanalyze
@@ -208,6 +210,24 @@ class IgnoredCallees(PyObjectSequenceOption[object]):
     name = "ignored_callees"
 
 
+TYPING_OBJECTS_SAFE_TO_CALL = [
+    getattr(mod, name)
+    for mod in (typing, typing_extensions)
+    for name in (
+        "TypeVar",
+        "TypeVarTuple",
+        "ParamSpec",
+        "NewType",
+        "TypeAliasType",
+        "NamedTuple",
+        "TypedDict",
+        "deprecated",
+        "dataclass_transform",
+    )
+    if hasattr(mod, name)
+]
+
+
 class ClassesSafeToInstantiate(PyObjectSequenceOption[type]):
     """We will instantiate instances of these classes if we can infer the value of all of
     their arguments. This is useful mostly for classes that are commonly instantiated with static
@@ -223,6 +243,7 @@ class ClassesSafeToInstantiate(PyObjectSequenceOption[type]):
         asynq.ConstFuture,
         range,
         tuple,
+        *[obj for obj in TYPING_OBJECTS_SAFE_TO_CALL if safe_isinstance(obj, type)],
     ]
 
 
@@ -232,7 +253,12 @@ class FunctionsSafeToCall(PyObjectSequenceOption[object]):
     arguments."""
 
     name = "functions_safe_to_call"
-    default_value = [sorted, asynq.asynq]
+    default_value = [
+        sorted,
+        asynq.asynq,
+        collections.namedtuple,
+        *[obj for obj in TYPING_OBJECTS_SAFE_TO_CALL if not safe_isinstance(obj, type)],
+    ]
 
 
 _HookReturn = Union[None, ConcreteSignature, inspect.Signature, Callable[..., Any]]
diff --git a/pyanalyze/implementation.py b/pyanalyze/implementation.py
index 0421fe1..88a92e5 100644
--- a/pyanalyze/implementation.py
+++ b/pyanalyze/implementation.py
@@ -1589,6 +1589,55 @@ def _any_impl(ctx: CallContext) -> Value:
     return AnyValue(AnySource.error)
 
 
+# Should not be necessary, but by default we pick up a wrong signature for
+# typing.NamedTuple
+def _namedtuple_impl(ctx: CallContext) -> Value:
+    has_kwargs = (
+        isinstance(ctx.vars["kwargs"], TypedDictValue)
+        and len(ctx.vars["kwargs"].items) > 0
+    )
+    # Mirrors the runtime logic in typing.NamedTuple in 3.13
+    if ctx.vars["fields"] is _NO_ARG_SENTINEL:
+        if has_kwargs:
+            ctx.show_error(
+                'Creating "NamedTuple" classes using keyword arguments'
+                " is deprecated and will be disallowed in Python 3.15. "
+                "Use the class-based or functional syntax instead.",
+                ErrorCode.deprecated,
+                arg="kwargs",
+            )
+        else:
+            ctx.show_error(
+                'Failing to pass a value for the "fields" parameter'
+                " is deprecated and will be disallowed in Python 3.15.",
+                ErrorCode.deprecated,
+            )
+    elif ctx.vars["fields"] == KnownValue(None):
+        if has_kwargs:
+            ctx.show_error(
+                'Cannot pass "None" as the "fields" parameter '
+                "and also specify fields using keyword arguments",
+                ErrorCode.incompatible_call,
+                arg="fields",
+            )
+        else:
+            ctx.show_error(
+                'Passing "None" as the "fields" parameter '
+                " is deprecated and will be disallowed in Python 3.15.",
+                ErrorCode.deprecated,
+                arg="fields",
+            )
+    elif has_kwargs:
+        ctx.show_error(
+            "Either list of fields or keywords"
+            ' can be provided to "NamedTuple", not both',
+            ErrorCode.incompatible_call,
+            arg="kwargs",
+        )
+
+    return AnyValue(AnySource.inference)
+
+
 _POS_ONLY = ParameterKind.POSITIONAL_ONLY
 _ENCODING_PARAMETER = SigParameter(
     "encoding", annotation=TypedValue(str), default=KnownValue("")
@@ -2090,7 +2139,6 @@ def get_default_argspecs() -> Dict[object, Signature]:
         except AttributeError:
             pass
         else:
-            # Anticipating https://bugs.python.org/issue46414
             sig = Signature.make(
                 [SigParameter("value", _POS_ONLY, annotation=TypeVarValue(T))],
                 return_annotation=TypeVarValue(T),
@@ -2098,7 +2146,6 @@ def get_default_argspecs() -> Dict[object, Signature]:
                 callable=reveal_type_func,
             )
             signatures.append(sig)
-        # Anticipating that this will be added to the stdlib
         try:
             assert_type_func = getattr(mod, "assert_type")
         except AttributeError:
@@ -2114,4 +2161,38 @@ def get_default_argspecs() -> Dict[object, Signature]:
                 impl=_assert_type_impl,
             )
             signatures.append(sig)
+        try:
+            namedtuple_func = getattr(mod, "NamedTuple")
+        except AttributeError:
+            pass
+        else:
+            sig = Signature.make(
+                [
+                    SigParameter("typename", _POS_ONLY, annotation=TypedValue(str)),
+                    SigParameter(
+                        "fields",
+                        _POS_ONLY,
+                        annotation=GenericValue(
+                            collections.abc.Iterable,
+                            [
+                                SequenceValue(
+                                    tuple,
+                                    [
+                                        (False, TypedValue(str)),
+                                        (False, AnyValue(AnySource.inference)),
+                                    ],
+                                )
+                            ],
+                        )
+                        | KnownValue(None),
+                        default=_NO_ARG_SENTINEL,
+                    ),
+                    SigParameter("kwargs", ParameterKind.VAR_KEYWORD),
+                ],
+                return_annotation=TypedValue(type),
+                callable=namedtuple_func,
+                impl=_namedtuple_impl,
+                allow_call=True,
+            )
+            signatures.append(sig)
     return {sig.callable: sig for sig in signatures}


Test patch:
diff --git a/pyanalyze/test_annotations.py b/pyanalyze/test_annotations.py
index 6c7dc05..fd35cdd 100644
--- a/pyanalyze/test_annotations.py
+++ b/pyanalyze/test_annotations.py
@@ -1900,3 +1900,38 @@ class TestMissinGenericParameters(TestNameCheckVisitorBase):
             z: float | bool | set,  # E: missing_generic_parameters
         ) -> None:
             pass
+
+
+class TestIfTypeChecking(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test_typevar(self):
+        from typing import TYPE_CHECKING, TypeVar
+
+        from typing_extensions import Literal, assert_type
+
+        if TYPE_CHECKING:
+
+            T = TypeVar("T")
+
+            def capybara(x: T) -> T:
+                return x
+
+            assert_type(capybara(1), Literal[1])
+
+    @assert_passes()
+    def test_namedtuple(self):
+        from collections import namedtuple
+        from typing import TYPE_CHECKING, Any, NamedTuple
+
+        from typing_extensions import assert_type
+
+        if TYPE_CHECKING:
+            TypedCapybara = NamedTuple("TypedCapybara", [("x", int), ("y", str)])
+            UntypedCapybara = namedtuple("UntypedCapybara", ["x", "y"])
+
+        def capybara(t: "TypedCapybara", u: "UntypedCapybara") -> None:
+            assert_type(t.x, int)
+            assert_type(t.y, str)
+
+            assert_type(u.x, Any)
+            assert_type(u.y, Any)
diff --git a/pyanalyze/test_implementation.py b/pyanalyze/test_implementation.py
index d5d5dc0..d9f83b0 100644
--- a/pyanalyze/test_implementation.py
+++ b/pyanalyze/test_implementation.py
@@ -1445,3 +1445,18 @@ class TestAny(TestNameCheckVisitorBase):
 
         def capybara():
             Any(42)  # E: incompatible_call
+
+
+class TestNamedTuple(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test_namedtuple(self):
+        from typing import NamedTuple
+
+        def capybara() -> None:
+            NamedTuple("x", y=int)  # E: deprecated
+            NamedTuple("x")  # E: deprecated
+            NamedTuple("x", None, y=int)  # E: incompatible_call
+            NamedTuple("x", None)  # E: deprecated
+            NamedTuple("x", [("y", int)], z=str)  # E: incompatible_call
+
+            NamedTuple("x", [("y", int)])  # ok