Repository: python/mypy_extensions

Issue:
Deprecate `mypy_extensions.TypedDict`?
`mypy_extensions.TypedDict` is problematic for several reasons:

- `mypy_extensions.TypedDict` is missing quite a few features that have been added to `typing.TypedDict` at runtime (and have been backported to `typing_extensions.TypedDict`).
- I think the existence of both `mypy_extensions.TypedDict` and `typing_extensions.TypedDict` has the potential to be pretty confusing for users.
- It's quite annoying and error-prone that at typeshed, we have to remember to keep `typing._TypedDict`, `typing_extensions._TypedDict` and `mypy_extensions._TypedDict` all in sync. Unfortunately, we can't put them all in `_typeshed` and have all three modules import `_TypedDict` from `_typeshed`, as the three classes all have slightly subtle differences. (E.g. `mypy_extensions._TypedDict` doesn't have the `__required_keys__` and `__optional_keys__` ClassVars that both `typing._TypedDict` and `typing_extensions._TypedDict` have.)
- Mypy now maintains its own copy of the `mypy-extensions` stubs anyway, so its stubs for `mypy_extensions` are no longer automatically updated with each typeshed sync. That means that even if we update the stubs for `mypy_extensions.TypedDict` in typeshed (as we did in https://github.com/python/typeshed/pull/10565), those updates are no longer of any benefit to mypy users unless mypy maintainers remember to copy across the changes to their forked version of the `mypy_extensions` stubs.

I propose that we deprecate `mypy_extensions.TypedDict`, and steer people towards `typing_extensions.TypedDict` instead: it's up-to-date with the features on `typing.TypedDict`, it's much more comprehensively tested, and it has up-to-date stubs.

Thoughts?

Discussion:
AlexWaygood: Friendly ping @JelleZijlstra / @ilevkivskyi :)

Patch:
diff --git a/mypy_extensions.py b/mypy_extensions.py
index 6600b21..aff5145 100644
--- a/mypy_extensions.py
+++ b/mypy_extensions.py
@@ -42,17 +42,32 @@ def _typeddict_new(cls, _typename, _fields=None, **kwargs):
     except (AttributeError, ValueError):
         pass
 
-    return _TypedDictMeta(_typename, (), ns)
+    return _TypedDictMeta(_typename, (), ns, _from_functional_call=True)
 
 
 class _TypedDictMeta(type):
-    def __new__(cls, name, bases, ns, total=True):
+    def __new__(cls, name, bases, ns, total=True, _from_functional_call=False):
         # Create new typed dict class object.
         # This method is called directly when TypedDict is subclassed,
         # or via _typeddict_new when TypedDict is instantiated. This way
         # TypedDict supports all three syntaxes described in its docstring.
         # Subclasses and instances of TypedDict return actual dictionaries
         # via _dict_new.
+
+        # We need the `if TypedDict in globals()` check,
+        # or we emit a DeprecationWarning when creating mypy_extensions.TypedDict itself
+        if 'TypedDict' in globals():
+            import warnings
+            warnings.warn(
+                (
+                    "mypy_extensions.TypedDict is deprecated, "
+                    "and will be removed in a future version. "
+                    "Use typing.TypedDict or typing_extensions.TypedDict instead."
+                ),
+                DeprecationWarning,
+                stacklevel=(3 if _from_functional_call else 2)
+            )
+
         ns['__new__'] = _typeddict_new if name == 'TypedDict' else _dict_new
         tp_dict = super(_TypedDictMeta, cls).__new__(cls, name, (dict,), ns)
 
diff --git a/tox.ini b/tox.ini
index 9f64a32..b0766b5 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,7 +5,7 @@ envlist = py35, py36, py37, py38, py39, py310, py311
 
 [testenv]
 description = run the test driver with {basepython}
-commands = python -m unittest discover tests
+commands = python -We -m unittest discover tests
 
 [testenv:lint]
 description = check the code style


Test patch:
diff --git a/tests/testextensions.py b/tests/testextensions.py
index 991c4e5..a41f5f6 100644
--- a/tests/testextensions.py
+++ b/tests/testextensions.py
@@ -1,6 +1,8 @@
 import sys
 import pickle
 import typing
+from contextlib import contextmanager
+from textwrap import dedent
 from unittest import TestCase, main, skipUnless
 from mypy_extensions import TypedDict, i64, i32, i16, u8
 
@@ -25,17 +27,22 @@ class BaseTestCase(TestCase):
 PY36 = sys.version_info[:2] >= (3, 6)
 
 PY36_TESTS = """
-Label = TypedDict('Label', [('label', str)])
+import warnings
 
-class Point2D(TypedDict):
-    x: int
-    y: int
+with warnings.catch_warnings():
+    warnings.simplefilter("ignore", category=DeprecationWarning)
 
-class LabelPoint2D(Point2D, Label): ...
+    Label = TypedDict('Label', [('label', str)])
 
-class Options(TypedDict, total=False):
-    log_level: int
-    log_path: str
+    class Point2D(TypedDict):
+        x: int
+        y: int
+
+    class LabelPoint2D(Point2D, Label): ...
+
+    class Options(TypedDict, total=False):
+        log_level: int
+        log_path: str
 """
 
 if PY36:
@@ -43,9 +50,16 @@ if PY36:
 
 
 class TypedDictTests(BaseTestCase):
+    @contextmanager
+    def assert_typeddict_deprecated(self):
+        with self.assertWarnsRegex(
+            DeprecationWarning, "mypy_extensions.TypedDict is deprecated"
+        ):
+            yield
 
     def test_basics_iterable_syntax(self):
-        Emp = TypedDict('Emp', {'name': str, 'id': int})
+        with self.assert_typeddict_deprecated():
+            Emp = TypedDict('Emp', {'name': str, 'id': int})
         self.assertIsSubclass(Emp, dict)
         self.assertIsSubclass(Emp, typing.MutableMapping)
         if sys.version_info[0] >= 3:
@@ -62,7 +76,8 @@ class TypedDictTests(BaseTestCase):
         self.assertEqual(Emp.__total__, True)
 
     def test_basics_keywords_syntax(self):
-        Emp = TypedDict('Emp', name=str, id=int)
+        with self.assert_typeddict_deprecated():
+            Emp = TypedDict('Emp', name=str, id=int)
         self.assertIsSubclass(Emp, dict)
         self.assertIsSubclass(Emp, typing.MutableMapping)
         if sys.version_info[0] >= 3:
@@ -79,7 +94,8 @@ class TypedDictTests(BaseTestCase):
         self.assertEqual(Emp.__total__, True)
 
     def test_typeddict_errors(self):
-        Emp = TypedDict('Emp', {'name': str, 'id': int})
+        with self.assert_typeddict_deprecated():
+            Emp = TypedDict('Emp', {'name': str, 'id': int})
         self.assertEqual(TypedDict.__module__, 'mypy_extensions')
         jim = Emp(name='Jim', id=1)
         with self.assertRaises(TypeError):
@@ -88,9 +104,9 @@ class TypedDictTests(BaseTestCase):
             isinstance(jim, Emp)  # type: ignore
         with self.assertRaises(TypeError):
             issubclass(dict, Emp)  # type: ignore
-        with self.assertRaises(TypeError):
+        with self.assertRaises(TypeError), self.assert_typeddict_deprecated():
             TypedDict('Hi', x=())
-        with self.assertRaises(TypeError):
+        with self.assertRaises(TypeError), self.assert_typeddict_deprecated():
             TypedDict('Hi', [('x', int), ('y', ())])
         with self.assertRaises(TypeError):
             TypedDict('Hi', [('x', int)], y=int)
@@ -109,9 +125,20 @@ class TypedDictTests(BaseTestCase):
         other = LabelPoint2D(x=0, y=1, label='hi')  # noqa
         self.assertEqual(other['label'], 'hi')
 
+    if PY36:
+        exec(dedent(
+            """
+            def test_py36_class_usage_emits_deprecations(self):
+                with self.assert_typeddict_deprecated():
+                    class Foo(TypedDict):
+                        bar: int
+            """
+        ))
+
     def test_pickle(self):
         global EmpD  # pickle wants to reference the class by name
-        EmpD = TypedDict('EmpD', name=str, id=int)
+        with self.assert_typeddict_deprecated():
+            EmpD = TypedDict('EmpD', name=str, id=int)
         jane = EmpD({'name': 'jane', 'id': 37})
         for proto in range(pickle.HIGHEST_PROTOCOL + 1):
             z = pickle.dumps(jane, proto)
@@ -123,13 +150,15 @@ class TypedDictTests(BaseTestCase):
             self.assertEqual(EmpDnew({'name': 'jane', 'id': 37}), jane)
 
     def test_optional(self):
-        EmpD = TypedDict('EmpD', name=str, id=int)
+        with self.assert_typeddict_deprecated():
+            EmpD = TypedDict('EmpD', name=str, id=int)
 
         self.assertEqual(typing.Optional[EmpD], typing.Union[None, EmpD])
         self.assertNotEqual(typing.List[EmpD], typing.Tuple[EmpD])
 
     def test_total(self):
-        D = TypedDict('D', {'x': int}, total=False)
+        with self.assert_typeddict_deprecated():
+            D = TypedDict('D', {'x': int}, total=False)
         self.assertEqual(D(), {})
         self.assertEqual(D(x=1), {'x': 1})
         self.assertEqual(D.__total__, False)