diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..26fb6703 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*.{py,pyi,rst,md,yml,yaml,toml,json}] +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space + +[*.{py,pyi,toml,json}] +indent_size = 4 + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..fc71a6e2 --- /dev/null +++ b/.flake8 @@ -0,0 +1,15 @@ +[flake8] + +max-line-length = 90 +ignore = + # irrelevant plugins + B3, + DW12, + # code is sometimes better without this + E129, + # consistency with mypy + W504 +exclude = + # tests have more relaxed formatting rules + # and its own specific config in .flake8-tests + src/test_typing_extensions.py, diff --git a/.flake8-tests b/.flake8-tests new file mode 100644 index 00000000..5a97fe89 --- /dev/null +++ b/.flake8-tests @@ -0,0 +1,28 @@ +# This configuration is specific to test_*.py; you need to invoke it +# by specifically naming this config, like this: +# +# $ flake8 --config=.flake8-tests [SOURCES] +# +# This will be possibly merged in the future. + +[flake8] +max-line-length = 100 +ignore = + # temporary ignores until we sort it out + B017, + E302, + E303, + E306, + E501, + E701, + E704, + F722, + F811, + F821, + F841, + W503, + # irrelevant plugins + B3, + DW12, + # consistency with mypy + W504 diff --git a/.github/ISSUE_TEMPLATE/documentation-issue.md b/.github/ISSUE_TEMPLATE/documentation-issue.md deleted file mode 100644 index 6122c8f5..00000000 --- a/.github/ISSUE_TEMPLATE/documentation-issue.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Documentation issue -about: Report a problem or suggest changes for the documentation at https://typing.readthedocs.io/ -title: '' -labels: 'topic: documentation' -assignees: '' - ---- - - diff --git a/.github/ISSUE_TEMPLATE/new-typing-feature.md b/.github/ISSUE_TEMPLATE/new-typing-feature.md deleted file mode 100644 index 733df29e..00000000 --- a/.github/ISSUE_TEMPLATE/new-typing-feature.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: New typing feature -about: Suggest a new feature for Python's typing system -title: '' -labels: 'topic: feature' -assignees: '' - ---- - - diff --git a/.github/ISSUE_TEMPLATE/other-issue.md b/.github/ISSUE_TEMPLATE/other-issue.md deleted file mode 100644 index 484282c7..00000000 --- a/.github/ISSUE_TEMPLATE/other-issue.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Other issue -about: Report any other issue -title: '' -labels: 'topic: other' -assignees: '' - ---- - - diff --git a/.github/ISSUE_TEMPLATE/typing-extensions-issue.md b/.github/ISSUE_TEMPLATE/typing-extensions-issue.md deleted file mode 100644 index 226796e6..00000000 --- a/.github/ISSUE_TEMPLATE/typing-extensions-issue.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: typing-extensions issue -about: Report a problem or suggest changes for the typing-extensions library -title: '' -labels: 'topic: typing-extensions' -assignees: '' - ---- - - diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml deleted file mode 100644 index 43711d0f..00000000 --- a/.github/workflows/build-docs.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Build the documentation - -on: - pull_request: - -permissions: - contents: read - -jobs: - build: - - name: Build documentation - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.9 - - name: Install dependencies - run: | - pip install --upgrade pip - pip install -r docs/requirements.txt - - name: Build the documentation - run: make -C docs html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 302b2cae..43986802 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: run: | # Be wary of running `pip install` here, since it becomes easy for us to # accidentally pick up typing_extensions as installed by a dependency - cd typing_extensions/src + cd src python -m unittest test_typing_extensions.py linting: @@ -61,4 +61,4 @@ jobs: run: flake8 - name: Lint tests - run: flake8 --config=.flake8-tests typing_extensions/src/test_typing_extensions.py + run: flake8 --config=.flake8-tests src/test_typing_extensions.py diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 25f95868..4e270719 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -29,7 +29,6 @@ jobs: - name: Build and install wheel run: | - cd typing_extensions python -m build . export path_to_file=$(find dist -type f -name "typing_extensions-*.whl") echo "::notice::Installing wheel: $path_to_file" @@ -60,7 +59,6 @@ jobs: - name: Build and install sdist run: | - cd typing_extensions python -m build . export path_to_file=$(find dist -type f -name "typing_extensions-*.tar.gz") echo "::notice::Installing sdist: $path_to_file" diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0ad58f48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +MANIFEST + +__pycache__/ +build/ +dist/ +tmp/ +venv*/ + +.cache/ +.idea/ +.tox/ +.venv*/ +.vscode/ + +*.swp +*.pyc +*.egg-info/ diff --git a/CHANGELOG b/CHANGELOG.md similarity index 88% rename from CHANGELOG rename to CHANGELOG.md index aa66e55c..ad7e43c9 100644 --- a/CHANGELOG +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# Release 4.3.0 (July 1, 2022) + +- Add `typing_extensions.NamedTuple`, allowing for generic `NamedTuple`s on + Python <3.11 (backport from python/cpython#92027, by Serhiy Storchaka). Patch + by Alex Waygood (@AlexWaygood). +- Adjust `typing_extensions.TypedDict` to allow for generic `TypedDict`s on + Python <3.11 (backport from python/cpython#27663, by Samodya Abey). Patch by + Alex Waygood (@AlexWaygood). + # Release 4.2.0 (April 17, 2022) - Re-export `typing.Unpack` and `typing.TypeVarTuple` on Python 3.11. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a58156bb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +Code in this repository should follow CPython's style guidelines and +contributors need to sign the PSF Contributor Agreement. + +# typing\_extensions + +The `typing_extensions` module provides a way to access new features from the standard +library `typing` module in older versions of Python. For example, Python 3.10 adds +`typing.TypeGuard`, but users of older versions of Python can use `typing_extensions` to +use `TypeGuard` in their code even if they are unable to upgrade to Python 3.10. + +If you contribute the runtime implementation of a new `typing` feature to CPython, you +are encouraged to also implement the feature in `typing_extensions`. Because the runtime +implementation of much of the infrastructure in the `typing` module has changed over +time, this may require different code for some older Python versions. + +`typing_extensions` may also include experimental features that are not yet part of the +standard library, so that users can experiment with them before they are added to the +standard library. Such features should ideally already be specified in a PEP or draft +PEP. + +`typing_extensions` supports Python versions 3.7 and up. + +# Versioning scheme + +Starting with version 4.0.0, `typing_extensions` uses +[Semantic Versioning](https://semver.org/). The major version is incremented for all +backwards-incompatible changes. + +# Workflow for PyPI releases + +- Ensure that GitHub Actions reports no errors. + +- Update the version number in `typing_extensions/pyproject.toml` and in + `typing_extensions/CHANGELOG.md`. + +- Make sure your environment is up to date + + - `git checkout main` + - `git pull` + - `python -m pip install --upgrade build twine` + +- Build the source and wheel distributions: + + - `cd typing_extensions` + - `rm -rf dist/` + - `python -m build .` + +- Install the built distributions locally and test (if you were using `tox`, you already + tested the source distribution). + +- Run `twine upload dist/*`. + +- Tag the release. The tag should be just the version number, e.g. `4.1.1`. + +- `git push --tags` diff --git a/README.md b/README.md new file mode 100644 index 00000000..c960e663 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# Typing Extensions + +[![Chat at https://gitter.im/python/typing](https://badges.gitter.im/python/typing.svg)](https://gitter.im/python/typing) + +## Overview + +The `typing_extensions` module serves two related purposes: + +- Enable use of new type system features on older Python versions. For example, + `typing.TypeGuard` is new in Python 3.10, but `typing_extensions` allows + users on Python 3.6 through 3.9 to use it too. +- Enable experimentation with new type system PEPs before they are accepted and + added to the `typing` module. + +New features may be added to `typing_extensions` as soon as they are specified +in a PEP that has been added to the [python/peps](https://github.com/python/peps) +repository. If the PEP is accepted, the feature will then be added to `typing` +for the next CPython release. No typing PEP has been rejected so far, so we +haven't yet figured out how to deal with that possibility. + +Starting with version 4.0.0, `typing_extensions` uses +[Semantic Versioning](https://semver.org/). The +major version is incremented for all backwards-incompatible changes. +Therefore, it's safe to depend +on `typing_extensions` like this: `typing_extensions >=x.y, <(x+1)`, +where `x.y` is the first version that includes all features you need. + +`typing_extensions` supports Python versions 3.7 and higher. In the future, +support for older Python versions will be dropped some time after that version +reaches end of life. + +## Included items + +This module currently contains the following: + +- Experimental features + + - (Currently none) + +- In `typing` since Python 3.11 + + - `assert_never` + - `assert_type` + - `clear_overloads` + - `@dataclass_transform()` (see PEP 681) + - `get_overloads` + - `LiteralString` (see PEP 675) + - `Never` + - `NotRequired` (see PEP 655) + - `reveal_type` + - `Required` (see PEP 655) + - `Self` (see PEP 673) + - `TypeVarTuple` (see PEP 646) + - `Unpack` (see PEP 646) + +- In `typing` since Python 3.10 + + - `Concatenate` (see PEP 612) + - `ParamSpec` (see PEP 612) + - `ParamSpecArgs` (see PEP 612) + - `ParamSpecKwargs` (see PEP 612) + - `TypeAlias` (see PEP 613) + - `TypeGuard` (see PEP 647) + - `is_typeddict` + +- In `typing` since Python 3.9 + + - `Annotated` (see PEP 593) + +- In `typing` since Python 3.8 + + - `final` (see PEP 591) + - `Final` (see PEP 591) + - `Literal` (see PEP 586) + - `Protocol` (see PEP 544) + - `runtime_checkable` (see PEP 544) + - `TypedDict` (see PEP 589) + - `get_origin` (`typing_extensions` provides this function only in Python 3.7+) + - `get_args` (`typing_extensions` provides this function only in Python 3.7+) + +- In `typing` since Python 3.7 + + - `OrderedDict` + +- In `typing` since Python 3.5 or 3.6 (see [the typing documentation](https://docs.python.org/3.10/library/typing.html) for details) + + - `AsyncContextManager` + - `AsyncGenerator` + - `AsyncIterable` + - `AsyncIterator` + - `Awaitable` + - `ChainMap` + - `ClassVar` (see PEP 526) + - `ContextManager` + - `Coroutine` + - `Counter` + - `DefaultDict` + - `Deque` + - `NamedTuple` + - `NewType` + - `NoReturn` + - `overload` + - `Text` + - `Type` + - `TYPE_CHECKING` + - `get_type_hints` + +# Other Notes and Limitations + +Certain objects were changed after they were added to `typing`, and +`typing_extensions` provides a backport even on newer Python versions: + +- `TypedDict` does not store runtime information + about which (if any) keys are non-required in Python 3.8, and does not + honor the `total` keyword with old-style `TypedDict()` in Python + 3.9.0 and 3.9.1. `TypedDict` also does not support multiple inheritance + with `typing.Generic` on Python <3.11. +- `get_origin` and `get_args` lack support for `Annotated` in + Python 3.8 and lack support for `ParamSpecArgs` and `ParamSpecKwargs` + in 3.9. +- `@final` was changed in Python 3.11 to set the `.__final__` attribute. +- `@overload` was changed in Python 3.11 to make function overloads + introspectable at runtime. In order to access overloads with + `typing_extensions.get_overloads()`, you must use + `@typing_extensions.overload`. +- `NamedTuple` was changed in Python 3.11 to allow for multiple inheritance + with `typing.Generic`. + +There are a few types whose interface was modified between different +versions of typing. For example, `typing.Sequence` was modified to +subclass `typing.Reversible` as of Python 3.5.3. + +These changes are _not_ backported to prevent subtle compatibility +issues when mixing the differing implementations of modified classes. + +Certain types have incorrect runtime behavior due to limitations of older +versions of the typing module: + +- `ParamSpec` and `Concatenate` will not work with `get_args` and + `get_origin`. Certain PEP 612 special cases in user-defined + `Generic`s are also not available. + +These types are only guaranteed to work for static type checking. + +## Running tests + +To run tests, navigate into the appropriate source directory and run +`test_typing_extensions.py`. diff --git a/README.rst b/README.rst deleted file mode 100644 index 3a23b755..00000000 --- a/README.rst +++ /dev/null @@ -1,152 +0,0 @@ -================= -Typing Extensions -================= - -.. image:: https://badges.gitter.im/python/typing.svg - :alt: Chat at https://gitter.im/python/typing - :target: https://gitter.im/python/typing?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge - -Overview -======== - -The ``typing_extensions`` module serves two related purposes: - -- Enable use of new type system features on older Python versions. For example, - ``typing.TypeGuard`` is new in Python 3.10, but ``typing_extensions`` allows - users on Python 3.6 through 3.9 to use it too. -- Enable experimentation with new type system PEPs before they are accepted and - added to the ``typing`` module. - -New features may be added to ``typing_extensions`` as soon as they are specified -in a PEP that has been added to the `python/peps `_ -repository. If the PEP is accepted, the feature will then be added to ``typing`` -for the next CPython release. No typing PEP has been rejected so far, so we -haven't yet figured out how to deal with that possibility. - -Starting with version 4.0.0, ``typing_extensions`` uses -`Semantic Versioning `_. The -major version is incremented for all backwards-incompatible changes. -Therefore, it's safe to depend -on ``typing_extensions`` like this: ``typing_extensions >=x.y, <(x+1)``, -where ``x.y`` is the first version that includes all features you need. - -``typing_extensions`` supports Python versions 3.7 and higher. In the future, -support for older Python versions will be dropped some time after that version -reaches end of life. - -Included items -============== - -This module currently contains the following: - -- Experimental features - - - ``@dataclass_transform()`` (see PEP 681) - -- In ``typing`` since Python 3.11 - - - ``assert_never`` - - ``assert_type`` - - ``clear_overloads`` - - ``get_overloads`` - - ``LiteralString`` (see PEP 675) - - ``Never`` - - ``NotRequired`` (see PEP 655) - - ``reveal_type`` - - ``Required`` (see PEP 655) - - ``Self`` (see PEP 673) - - ``TypeVarTuple`` (see PEP 646) - - ``Unpack`` (see PEP 646) - -- In ``typing`` since Python 3.10 - - - ``Concatenate`` (see PEP 612) - - ``ParamSpec`` (see PEP 612) - - ``ParamSpecArgs`` (see PEP 612) - - ``ParamSpecKwargs`` (see PEP 612) - - ``TypeAlias`` (see PEP 613) - - ``TypeGuard`` (see PEP 647) - - ``is_typeddict`` - -- In ``typing`` since Python 3.9 - - - ``Annotated`` (see PEP 593) - -- In ``typing`` since Python 3.8 - - - ``final`` (see PEP 591) - - ``Final`` (see PEP 591) - - ``Literal`` (see PEP 586) - - ``Protocol`` (see PEP 544) - - ``runtime_checkable`` (see PEP 544) - - ``TypedDict`` (see PEP 589) - - ``get_origin`` (``typing_extensions`` provides this function only in Python 3.7+) - - ``get_args`` (``typing_extensions`` provides this function only in Python 3.7+) - -- In ``typing`` since Python 3.7 - - - ``OrderedDict`` - -- In ``typing`` since Python 3.5 or 3.6 (see `the typing documentation - `_ for details) - - - ``AsyncContextManager`` - - ``AsyncGenerator`` - - ``AsyncIterable`` - - ``AsyncIterator`` - - ``Awaitable`` - - ``ChainMap`` - - ``ClassVar`` (see PEP 526) - - ``ContextManager`` - - ``Coroutine`` - - ``Counter`` - - ``DefaultDict`` - - ``Deque`` - - ``NewType`` - - ``NoReturn`` - - ``overload`` - - ``Text`` - - ``Type`` - - ``TYPE_CHECKING`` - - ``get_type_hints`` - -Other Notes and Limitations -=========================== - -Certain objects were changed after they were added to ``typing``, and -``typing_extensions`` provides a backport even on newer Python versions: - -- ``TypedDict`` does not store runtime information - about which (if any) keys are non-required in Python 3.8, and does not - honor the "total" keyword with old-style ``TypedDict()`` in Python - 3.9.0 and 3.9.1. -- ``get_origin`` and ``get_args`` lack support for ``Annotated`` in - Python 3.8 and lack support for ``ParamSpecArgs`` and ``ParamSpecKwargs`` - in 3.9. -- ``@final`` was changed in Python 3.11 to set the ``.__final__`` attribute. -- ``@overload`` was changed in Python 3.11 to make function overloads - introspectable at runtime. In order to access overloads with - ``typing_extensions.get_overloads()``, you must use - ``@typing_extensions.overload``. - -There are a few types whose interface was modified between different -versions of typing. For example, ``typing.Sequence`` was modified to -subclass ``typing.Reversible`` as of Python 3.5.3. - -These changes are _not_ backported to prevent subtle compatibility -issues when mixing the differing implementations of modified classes. - -Certain types have incorrect runtime behavior due to limitations of older -versions of the typing module: - -- ``ParamSpec`` and ``Concatenate`` will not work with ``get_args`` and - ``get_origin``. Certain PEP 612 special cases in user-defined - ``Generic``\ s are also not available. - -These types are only guaranteed to work for static type checking. - -Running tests -============= - -To run tests, navigate into the appropriate source directory and run -``test_typing_extensions.py``. diff --git a/pyproject.toml b/pyproject.toml index 217b9499..6005d55c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,11 +6,10 @@ build-backend = "flit_core.buildapi" # Project metadata [project] name = "typing_extensions" -version = "4.2.0" +version = "4.3.0" description = "Backported and Experimental Type Hints for Python 3.7+" -readme = "README.rst" +readme = "README.md" requires-python = ">=3.7" -urls.Home = "https://github.com/python/typing/blob/master/typing_extensions/README.rst" license.file = "LICENSE" keywords = [ "annotations", @@ -24,7 +23,7 @@ keywords = [ "typechecking", "typehinting", "typehints", - "typing" + "typing", ] # Classifiers list: https://pypi.org/classifiers/ classifiers = [ @@ -35,14 +34,21 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", - "Topic :: Software Development" + "Topic :: Software Development", ] +[project.urls] +Home = "https://github.com/python/typing_extensions" +Repository = "https://github.com/python/typing_extensions" +Changes = "https://github.com/python/typing_extensions/blob/main/CHANGELOG.md" +Documentation = "https://typing.readthedocs.io/" +"Bug Tracker" = "https://github.com/python/typing_extensions/issues" +"Q & A" = "https://github.com/python/typing/discussions" + # Project metadata -- authors. Flit stores this as a list of dicts, so it can't # be inline above. [[project.authors]] @@ -50,9 +56,5 @@ name = "Guido van Rossum, Jukka Lehtosalo, Ɓukasz Langa, Michael Lee" email = "levkivskyi@gmail.com" [tool.flit.sdist] -include = [ - "CHANGELOG", - "README.rst", - "*/test*.py" -] +include = ["CHANGELOG.md", "README.md", "*/test*.py"] exclude = [] diff --git a/src/_typed_dict_test_helper.py b/src/_typed_dict_test_helper.py new file mode 100644 index 00000000..396a94fe --- /dev/null +++ b/src/_typed_dict_test_helper.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from typing import Generic, Optional, T +from typing_extensions import TypedDict + + +class FooGeneric(TypedDict, Generic[T]): + a: Optional[T] diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 7f14f3f9..ee498e56 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5,6 +5,7 @@ import collections from collections import defaultdict import collections.abc +import copy from functools import lru_cache import inspect import pickle @@ -17,7 +18,7 @@ from typing import TypeVar, Optional, Union, Any, AnyStr from typing import T, KT, VT # Not in __all__. from typing import Tuple, List, Dict, Iterable, Iterator, Callable -from typing import Generic, NamedTuple +from typing import Generic from typing import no_type_check import typing_extensions from typing_extensions import NoReturn, ClassVar, Final, IntVar, Literal, Type, NewType, TypedDict, Self @@ -27,10 +28,13 @@ from typing_extensions import TypeVarTuple, Unpack, dataclass_transform, reveal_type, Never, assert_never, LiteralString from typing_extensions import assert_type, get_type_hints, get_origin, get_args from typing_extensions import clear_overloads, get_overloads, overload +from typing_extensions import NamedTuple +from _typed_dict_test_helper import FooGeneric # Flags used to mark tests that only apply after a specific # version of the typing module. TYPING_3_8_0 = sys.version_info[:3] >= (3, 8, 0) +TYPING_3_9_0 = sys.version_info[:3] >= (3, 9, 0) TYPING_3_10_0 = sys.version_info[:3] >= (3, 10, 0) # 3.11 makes runtime type checks (_type_check) more lenient. @@ -1661,6 +1665,15 @@ class CustomProtocolWithoutInitB(Protocol): self.assertEqual(CustomProtocolWithoutInitA.__init__, CustomProtocolWithoutInitB.__init__) +class Point2DGeneric(Generic[T], TypedDict): + a: T + b: T + + +class BarGeneric(FooGeneric[T], total=False): + b: int + + class TypedDictTests(BaseTestCase): def test_basics_iterable_syntax(self): @@ -1766,7 +1779,9 @@ def test_pickle(self): global EmpD # pickle wants to reference the class by name EmpD = TypedDict('EmpD', name=str, id=int) jane = EmpD({'name': 'jane', 'id': 37}) + point = Point2DGeneric(a=5.0, b=3.0) for proto in range(pickle.HIGHEST_PROTOCOL + 1): + # Test non-generic TypedDict z = pickle.dumps(jane, proto) jane2 = pickle.loads(z) self.assertEqual(jane2, jane) @@ -1774,6 +1789,14 @@ def test_pickle(self): ZZ = pickle.dumps(EmpD, proto) EmpDnew = pickle.loads(ZZ) self.assertEqual(EmpDnew({'name': 'jane', 'id': 37}), jane) + # and generic TypedDict + y = pickle.dumps(point, proto) + point2 = pickle.loads(y) + self.assertEqual(point, point2) + self.assertEqual(point2, {'a': 5.0, 'b': 3.0}) + YY = pickle.dumps(Point2DGeneric, proto) + Point2DGenericNew = pickle.loads(YY) + self.assertEqual(Point2DGenericNew({'a': 5.0, 'b': 3.0}), point) def test_optional(self): EmpD = TypedDict('EmpD', name=str, id=int) @@ -1851,6 +1874,124 @@ class PointDict3D(PointDict2D, total=False): assert is_typeddict(PointDict2D) is True assert is_typeddict(PointDict3D) is True + def test_get_type_hints_generic(self): + self.assertEqual( + get_type_hints(BarGeneric), + {'a': typing.Optional[T], 'b': int} + ) + + class FooBarGeneric(BarGeneric[int]): + c: str + + self.assertEqual( + get_type_hints(FooBarGeneric), + {'a': typing.Optional[T], 'b': int, 'c': str} + ) + + def test_generic_inheritance(self): + class A(TypedDict, Generic[T]): + a: T + + self.assertEqual(A.__bases__, (Generic, dict)) + self.assertEqual(A.__orig_bases__, (TypedDict, Generic[T])) + self.assertEqual(A.__mro__, (A, Generic, dict, object)) + self.assertEqual(A.__parameters__, (T,)) + self.assertEqual(A[str].__parameters__, ()) + self.assertEqual(A[str].__args__, (str,)) + + class A2(Generic[T], TypedDict): + a: T + + self.assertEqual(A2.__bases__, (Generic, dict)) + self.assertEqual(A2.__orig_bases__, (Generic[T], TypedDict)) + self.assertEqual(A2.__mro__, (A2, Generic, dict, object)) + self.assertEqual(A2.__parameters__, (T,)) + self.assertEqual(A2[str].__parameters__, ()) + self.assertEqual(A2[str].__args__, (str,)) + + class B(A[KT], total=False): + b: KT + + self.assertEqual(B.__bases__, (Generic, dict)) + self.assertEqual(B.__orig_bases__, (A[KT],)) + self.assertEqual(B.__mro__, (B, Generic, dict, object)) + self.assertEqual(B.__parameters__, (KT,)) + self.assertEqual(B.__total__, False) + self.assertEqual(B.__optional_keys__, frozenset(['b'])) + self.assertEqual(B.__required_keys__, frozenset(['a'])) + + self.assertEqual(B[str].__parameters__, ()) + self.assertEqual(B[str].__args__, (str,)) + self.assertEqual(B[str].__origin__, B) + + class C(B[int]): + c: int + + self.assertEqual(C.__bases__, (Generic, dict)) + self.assertEqual(C.__orig_bases__, (B[int],)) + self.assertEqual(C.__mro__, (C, Generic, dict, object)) + self.assertEqual(C.__parameters__, ()) + self.assertEqual(C.__total__, True) + self.assertEqual(C.__optional_keys__, frozenset(['b'])) + self.assertEqual(C.__required_keys__, frozenset(['a', 'c'])) + assert C.__annotations__ == { + 'a': T, + 'b': KT, + 'c': int, + } + with self.assertRaises(TypeError): + C[str] + + + class Point3D(Point2DGeneric[T], Generic[T, KT]): + c: KT + + self.assertEqual(Point3D.__bases__, (Generic, dict)) + self.assertEqual(Point3D.__orig_bases__, (Point2DGeneric[T], Generic[T, KT])) + self.assertEqual(Point3D.__mro__, (Point3D, Generic, dict, object)) + self.assertEqual(Point3D.__parameters__, (T, KT)) + self.assertEqual(Point3D.__total__, True) + self.assertEqual(Point3D.__optional_keys__, frozenset()) + self.assertEqual(Point3D.__required_keys__, frozenset(['a', 'b', 'c'])) + assert Point3D.__annotations__ == { + 'a': T, + 'b': T, + 'c': KT, + } + self.assertEqual(Point3D[int, str].__origin__, Point3D) + + with self.assertRaises(TypeError): + Point3D[int] + + with self.assertRaises(TypeError): + class Point3D(Point2DGeneric[T], Generic[KT]): + c: KT + + def test_implicit_any_inheritance(self): + class A(TypedDict, Generic[T]): + a: T + + class B(A[KT], total=False): + b: KT + + class WithImplicitAny(B): + c: int + + self.assertEqual(WithImplicitAny.__bases__, (Generic, dict,)) + self.assertEqual(WithImplicitAny.__mro__, (WithImplicitAny, Generic, dict, object)) + # Consistent with GenericTests.test_implicit_any + self.assertEqual(WithImplicitAny.__parameters__, ()) + self.assertEqual(WithImplicitAny.__total__, True) + self.assertEqual(WithImplicitAny.__optional_keys__, frozenset(['b'])) + self.assertEqual(WithImplicitAny.__required_keys__, frozenset(['a', 'c'])) + assert WithImplicitAny.__annotations__ == { + 'a': T, + 'b': KT, + 'c': int, + } + with self.assertRaises(TypeError): + WithImplicitAny[str] + class AnnotatedTests(BaseTestCase): @@ -2874,7 +3015,7 @@ def test_typing_extensions_defers_when_possible(self): if sys.version_info < (3, 10): exclude |= {'get_args', 'get_origin'} if sys.version_info < (3, 11): - exclude.add('final') + exclude |= {'final', 'NamedTuple'} for item in typing_extensions.__all__: if item not in exclude and hasattr(typing, item): self.assertIs( @@ -2892,6 +3033,305 @@ def test_typing_extensions_compiles_with_opt(self): self.fail('Module does not compile with optimize=2 (-OO flag).') +class CoolEmployee(NamedTuple): + name: str + cool: int + + +class CoolEmployeeWithDefault(NamedTuple): + name: str + cool: int = 0 + + +class XMeth(NamedTuple): + x: int + + def double(self): + return 2 * self.x + + +class XRepr(NamedTuple): + x: int + y: int = 1 + + def __str__(self): + return f'{self.x} -> {self.y}' + + def __add__(self, other): + return 0 + + +@skipIf(TYPING_3_11_0, "These invariants should all be tested upstream on 3.11+") +class NamedTupleTests(BaseTestCase): + class NestedEmployee(NamedTuple): + name: str + cool: int + + def test_basics(self): + Emp = NamedTuple('Emp', [('name', str), ('id', int)]) + self.assertIsSubclass(Emp, tuple) + joe = Emp('Joe', 42) + jim = Emp(name='Jim', id=1) + self.assertIsInstance(joe, Emp) + self.assertIsInstance(joe, tuple) + self.assertEqual(joe.name, 'Joe') + self.assertEqual(joe.id, 42) + self.assertEqual(jim.name, 'Jim') + self.assertEqual(jim.id, 1) + self.assertEqual(Emp.__name__, 'Emp') + self.assertEqual(Emp._fields, ('name', 'id')) + self.assertEqual(Emp.__annotations__, + collections.OrderedDict([('name', str), ('id', int)])) + + def test_annotation_usage(self): + tim = CoolEmployee('Tim', 9000) + self.assertIsInstance(tim, CoolEmployee) + self.assertIsInstance(tim, tuple) + self.assertEqual(tim.name, 'Tim') + self.assertEqual(tim.cool, 9000) + self.assertEqual(CoolEmployee.__name__, 'CoolEmployee') + self.assertEqual(CoolEmployee._fields, ('name', 'cool')) + self.assertEqual(CoolEmployee.__annotations__, + collections.OrderedDict(name=str, cool=int)) + + def test_annotation_usage_with_default(self): + jelle = CoolEmployeeWithDefault('Jelle') + self.assertIsInstance(jelle, CoolEmployeeWithDefault) + self.assertIsInstance(jelle, tuple) + self.assertEqual(jelle.name, 'Jelle') + self.assertEqual(jelle.cool, 0) + cooler_employee = CoolEmployeeWithDefault('Sjoerd', 1) + self.assertEqual(cooler_employee.cool, 1) + + self.assertEqual(CoolEmployeeWithDefault.__name__, 'CoolEmployeeWithDefault') + self.assertEqual(CoolEmployeeWithDefault._fields, ('name', 'cool')) + self.assertEqual(CoolEmployeeWithDefault.__annotations__, + dict(name=str, cool=int)) + + with self.assertRaisesRegex( + TypeError, + 'Non-default namedtuple field y cannot follow default field x' + ): + class NonDefaultAfterDefault(NamedTuple): + x: int = 3 + y: int + + @skipUnless( + ( + TYPING_3_8_0 + or hasattr(CoolEmployeeWithDefault, '_field_defaults') + ), + '"_field_defaults" attribute was added in a micro version of 3.7' + ) + def test_field_defaults(self): + self.assertEqual(CoolEmployeeWithDefault._field_defaults, dict(cool=0)) + + def test_annotation_usage_with_methods(self): + self.assertEqual(XMeth(1).double(), 2) + self.assertEqual(XMeth(42).x, XMeth(42)[0]) + self.assertEqual(str(XRepr(42)), '42 -> 1') + self.assertEqual(XRepr(1, 2) + XRepr(3), 0) + + bad_overwrite_error_message = 'Cannot overwrite NamedTuple attribute' + + with self.assertRaisesRegex(AttributeError, bad_overwrite_error_message): + class XMethBad(NamedTuple): + x: int + def _fields(self): + return 'no chance for this' + + with self.assertRaisesRegex(AttributeError, bad_overwrite_error_message): + class XMethBad2(NamedTuple): + x: int + def _source(self): + return 'no chance for this as well' + + def test_multiple_inheritance(self): + class A: + pass + with self.assertRaisesRegex( + TypeError, + 'can only inherit from a NamedTuple type and Generic' + ): + class X(NamedTuple, A): + x: int + + with self.assertRaisesRegex( + TypeError, + 'can only inherit from a NamedTuple type and Generic' + ): + class X(NamedTuple, tuple): + x: int + + with self.assertRaisesRegex(TypeError, 'duplicate base class'): + class X(NamedTuple, NamedTuple): + x: int + + class A(NamedTuple): + x: int + with self.assertRaisesRegex( + TypeError, + 'can only inherit from a NamedTuple type and Generic' + ): + class X(NamedTuple, A): + y: str + + def test_generic(self): + class X(NamedTuple, Generic[T]): + x: T + self.assertEqual(X.__bases__, (tuple, Generic)) + self.assertEqual(X.__orig_bases__, (NamedTuple, Generic[T])) + self.assertEqual(X.__mro__, (X, tuple, Generic, object)) + + class Y(Generic[T], NamedTuple): + x: T + self.assertEqual(Y.__bases__, (Generic, tuple)) + self.assertEqual(Y.__orig_bases__, (Generic[T], NamedTuple)) + self.assertEqual(Y.__mro__, (Y, Generic, tuple, object)) + + for G in X, Y: + with self.subTest(type=G): + self.assertEqual(G.__parameters__, (T,)) + A = G[int] + self.assertIs(A.__origin__, G) + self.assertEqual(A.__args__, (int,)) + self.assertEqual(A.__parameters__, ()) + + a = A(3) + self.assertIs(type(a), G) + self.assertEqual(a.x, 3) + + with self.assertRaisesRegex(TypeError, 'Too many parameters'): + G[int, str] + + @skipUnless(TYPING_3_9_0, "tuple.__class_getitem__ was added in 3.9") + def test_non_generic_subscript_py39_plus(self): + # For backward compatibility, subscription works + # on arbitrary NamedTuple types. + class Group(NamedTuple): + key: T + group: list[T] + A = Group[int] + self.assertEqual(A.__origin__, Group) + self.assertEqual(A.__parameters__, ()) + self.assertEqual(A.__args__, (int,)) + a = A(1, [2]) + self.assertIs(type(a), Group) + self.assertEqual(a, (1, [2])) + + @skipIf(TYPING_3_9_0, "Test isn't relevant to 3.9+") + def test_non_generic_subscript_error_message_py38_minus(self): + class Group(NamedTuple): + key: T + group: List[T] + + with self.assertRaisesRegex(TypeError, 'not subscriptable'): + Group[int] + + for attr in ('__args__', '__origin__', '__parameters__'): + with self.subTest(attr=attr): + self.assertFalse(hasattr(Group, attr)) + + def test_namedtuple_keyword_usage(self): + LocalEmployee = NamedTuple("LocalEmployee", name=str, age=int) + nick = LocalEmployee('Nick', 25) + self.assertIsInstance(nick, tuple) + self.assertEqual(nick.name, 'Nick') + self.assertEqual(LocalEmployee.__name__, 'LocalEmployee') + self.assertEqual(LocalEmployee._fields, ('name', 'age')) + self.assertEqual(LocalEmployee.__annotations__, dict(name=str, age=int)) + with self.assertRaisesRegex( + TypeError, + 'Either list of fields or keywords can be provided to NamedTuple, not both' + ): + NamedTuple('Name', [('x', int)], y=str) + + def test_namedtuple_special_keyword_names(self): + NT = NamedTuple("NT", cls=type, self=object, typename=str, fields=list) + self.assertEqual(NT.__name__, 'NT') + self.assertEqual(NT._fields, ('cls', 'self', 'typename', 'fields')) + a = NT(cls=str, self=42, typename='foo', fields=[('bar', tuple)]) + self.assertEqual(a.cls, str) + self.assertEqual(a.self, 42) + self.assertEqual(a.typename, 'foo') + self.assertEqual(a.fields, [('bar', tuple)]) + + def test_empty_namedtuple(self): + NT = NamedTuple('NT') + + class CNT(NamedTuple): + pass # empty body + + for struct in [NT, CNT]: + with self.subTest(struct=struct): + self.assertEqual(struct._fields, ()) + self.assertEqual(struct.__annotations__, {}) + self.assertIsInstance(struct(), struct) + # Attribute was added in a micro version of 3.7 + # and is tested more fully elsewhere + if hasattr(struct, "_field_defaults"): + self.assertEqual(struct._field_defaults, {}) + + def test_namedtuple_errors(self): + with self.assertRaises(TypeError): + NamedTuple.__new__() + with self.assertRaises(TypeError): + NamedTuple() + with self.assertRaises(TypeError): + NamedTuple('Emp', [('name', str)], None) + with self.assertRaisesRegex(ValueError, 'cannot start with an underscore'): + NamedTuple('Emp', [('_name', str)]) + with self.assertRaises(TypeError): + NamedTuple(typename='Emp', name=str, id=int) + + def test_copy_and_pickle(self): + global Emp # pickle wants to reference the class by name + Emp = NamedTuple('Emp', [('name', str), ('cool', int)]) + for cls in Emp, CoolEmployee, self.NestedEmployee: + with self.subTest(cls=cls): + jane = cls('jane', 37) + for proto in range(pickle.HIGHEST_PROTOCOL + 1): + z = pickle.dumps(jane, proto) + jane2 = pickle.loads(z) + self.assertEqual(jane2, jane) + self.assertIsInstance(jane2, cls) + + jane2 = copy.copy(jane) + self.assertEqual(jane2, jane) + self.assertIsInstance(jane2, cls) + + jane2 = copy.deepcopy(jane) + self.assertEqual(jane2, jane) + self.assertIsInstance(jane2, cls) + + def test_docstring(self): + self.assertEqual(NamedTuple.__doc__, typing.NamedTuple.__doc__) + self.assertIsInstance(NamedTuple.__doc__, str) + + @skipUnless(TYPING_3_8_0, "NamedTuple had a bad signature on <=3.7") + def test_signature_is_same_as_typing_NamedTuple(self): + self.assertEqual(inspect.signature(NamedTuple), inspect.signature(typing.NamedTuple)) + + @skipIf(TYPING_3_8_0, "tests are only relevant to <=3.7") + def test_signature_on_37(self): + self.assertIsInstance(inspect.signature(NamedTuple), inspect.Signature) + self.assertFalse(hasattr(NamedTuple, "__text_signature__")) + + @skipUnless(TYPING_3_9_0, "NamedTuple was a class on 3.8 and lower") + def test_same_as_typing_NamedTuple_39_plus(self): + self.assertEqual( + set(dir(NamedTuple)), + set(dir(typing.NamedTuple)) | {"__text_signature__"} + ) + self.assertIs(type(NamedTuple), type(typing.NamedTuple)) + + @skipIf(TYPING_3_9_0, "tests are only relevant to <=3.8") + def test_same_as_typing_NamedTuple_38_minus(self): + self.assertEqual( + self.NestedEmployee.__annotations__, + self.NestedEmployee._field_types + ) + if __name__ == '__main__': main() diff --git a/src/typing_extensions.py b/src/typing_extensions.py index dc038819..31d3564e 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -37,6 +37,7 @@ 'Counter', 'Deque', 'DefaultDict', + 'NamedTuple', 'OrderedDict', 'TypedDict', @@ -380,6 +381,46 @@ def _is_callable_members_only(cls): return all(callable(getattr(cls, attr, None)) for attr in _get_protocol_attrs(cls)) +def _maybe_adjust_parameters(cls): + """Helper function used in Protocol.__init_subclass__ and _TypedDictMeta.__new__. + + The contents of this function are very similar + to logic found in typing.Generic.__init_subclass__ + on the CPython main branch. + """ + tvars = [] + if '__orig_bases__' in cls.__dict__: + tvars = typing._collect_type_vars(cls.__orig_bases__) + # Look for Generic[T1, ..., Tn] or Protocol[T1, ..., Tn]. + # If found, tvars must be a subset of it. + # If not found, tvars is it. + # Also check for and reject plain Generic, + # and reject multiple Generic[...] and/or Protocol[...]. + gvars = None + for base in cls.__orig_bases__: + if (isinstance(base, typing._GenericAlias) and + base.__origin__ in (typing.Generic, Protocol)): + # for error messages + the_base = base.__origin__.__name__ + if gvars is not None: + raise TypeError( + "Cannot inherit from Generic[...]" + " and/or Protocol[...] multiple types.") + gvars = base.__parameters__ + if gvars is None: + gvars = tvars + else: + tvarset = set(tvars) + gvarset = set(gvars) + if not tvarset <= gvarset: + s_vars = ', '.join(str(t) for t in tvars if t not in gvarset) + s_args = ', '.join(str(g) for g in gvars) + raise TypeError(f"Some type variables ({s_vars}) are" + f" not listed in {the_base}[{s_args}]") + tvars = gvars + cls.__parameters__ = tuple(tvars) + + # 3.8+ if hasattr(typing, 'Protocol'): Protocol = typing.Protocol @@ -476,43 +517,13 @@ def __class_getitem__(cls, params): return typing._GenericAlias(cls, params) def __init_subclass__(cls, *args, **kwargs): - tvars = [] if '__orig_bases__' in cls.__dict__: error = typing.Generic in cls.__orig_bases__ else: error = typing.Generic in cls.__bases__ if error: raise TypeError("Cannot inherit from plain Generic") - if '__orig_bases__' in cls.__dict__: - tvars = typing._collect_type_vars(cls.__orig_bases__) - # Look for Generic[T1, ..., Tn] or Protocol[T1, ..., Tn]. - # If found, tvars must be a subset of it. - # If not found, tvars is it. - # Also check for and reject plain Generic, - # and reject multiple Generic[...] and/or Protocol[...]. - gvars = None - for base in cls.__orig_bases__: - if (isinstance(base, typing._GenericAlias) and - base.__origin__ in (typing.Generic, Protocol)): - # for error messages - the_base = base.__origin__.__name__ - if gvars is not None: - raise TypeError( - "Cannot inherit from Generic[...]" - " and/or Protocol[...] multiple types.") - gvars = base.__parameters__ - if gvars is None: - gvars = tvars - else: - tvarset = set(tvars) - gvarset = set(gvars) - if not tvarset <= gvarset: - s_vars = ', '.join(str(t) for t in tvars if t not in gvarset) - s_args = ', '.join(str(g) for g in gvars) - raise TypeError(f"Some type variables ({s_vars}) are" - f" not listed in {the_base}[{s_args}]") - tvars = gvars - cls.__parameters__ = tuple(tvars) + _maybe_adjust_parameters(cls) # Determine if this is a protocol or a concrete subclass. if not cls.__dict__.get('_is_protocol', None): @@ -613,6 +624,7 @@ def __index__(self) -> int: # keyword with old-style TypedDict(). See https://bugs.python.org/issue42059 # The standard library TypedDict below Python 3.11 does not store runtime # information about optional and required keys when using Required or NotRequired. + # Generic TypedDicts are also impossible using typing.TypedDict on Python <3.11. TypedDict = typing.TypedDict _TypedDictMeta = typing._TypedDictMeta is_typeddict = typing.is_typeddict @@ -695,8 +707,16 @@ def __new__(cls, name, bases, ns, total=True): # Subclasses and instances of TypedDict return actual dictionaries # via _dict_new. ns['__new__'] = _typeddict_new if name == 'TypedDict' else _dict_new + # Don't insert typing.Generic into __bases__ here, + # or Generic.__init_subclass__ will raise TypeError + # in the super().__new__() call. + # Instead, monkey-patch __bases__ onto the class after it's been created. tp_dict = super().__new__(cls, name, (dict,), ns) + if any(issubclass(base, typing.Generic) for base in bases): + tp_dict.__bases__ = (typing.Generic, dict) + _maybe_adjust_parameters(tp_dict) + annotations = {} own_annotations = ns.get('__annotations__', {}) msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type" @@ -1958,3 +1978,92 @@ def decorator(cls_or_fn): if not hasattr(typing, "TypeVarTuple"): typing._collect_type_vars = _collect_type_vars typing._check_generic = _check_generic + + +# Backport typing.NamedTuple as it exists in Python 3.11. +# In 3.11, the ability to define generic `NamedTuple`s was supported. +# This was explicitly disallowed in 3.9-3.10, and only half-worked in <=3.8. +if sys.version_info >= (3, 11): + NamedTuple = typing.NamedTuple +else: + def _caller(): + try: + return sys._getframe(2).f_globals.get('__name__', '__main__') + except (AttributeError, ValueError): # For platforms without _getframe() + return None + + def _make_nmtuple(name, types, module, defaults=()): + fields = [n for n, t in types] + annotations = {n: typing._type_check(t, f"field {n} annotation must be a type") + for n, t in types} + nm_tpl = collections.namedtuple(name, fields, + defaults=defaults, module=module) + nm_tpl.__annotations__ = nm_tpl.__new__.__annotations__ = annotations + # The `_field_types` attribute was removed in 3.9; + # in earlier versions, it is the same as the `__annotations__` attribute + if sys.version_info < (3, 9): + nm_tpl._field_types = annotations + return nm_tpl + + _prohibited_namedtuple_fields = typing._prohibited + _special_namedtuple_fields = frozenset({'__module__', '__name__', '__annotations__'}) + + class _NamedTupleMeta(type): + def __new__(cls, typename, bases, ns): + assert _NamedTuple in bases + for base in bases: + if base is not _NamedTuple and base is not typing.Generic: + raise TypeError( + 'can only inherit from a NamedTuple type and Generic') + bases = tuple(tuple if base is _NamedTuple else base for base in bases) + types = ns.get('__annotations__', {}) + default_names = [] + for field_name in types: + if field_name in ns: + default_names.append(field_name) + elif default_names: + raise TypeError(f"Non-default namedtuple field {field_name} " + f"cannot follow default field" + f"{'s' if len(default_names) > 1 else ''} " + f"{', '.join(default_names)}") + nm_tpl = _make_nmtuple( + typename, types.items(), + defaults=[ns[n] for n in default_names], + module=ns['__module__'] + ) + nm_tpl.__bases__ = bases + if typing.Generic in bases: + class_getitem = typing.Generic.__class_getitem__.__func__ + nm_tpl.__class_getitem__ = classmethod(class_getitem) + # update from user namespace without overriding special namedtuple attributes + for key in ns: + if key in _prohibited_namedtuple_fields: + raise AttributeError("Cannot overwrite NamedTuple attribute " + key) + elif key not in _special_namedtuple_fields and key not in nm_tpl._fields: + setattr(nm_tpl, key, ns[key]) + if typing.Generic in bases: + nm_tpl.__init_subclass__() + return nm_tpl + + def NamedTuple(__typename, __fields=None, **kwargs): + if __fields is None: + __fields = kwargs.items() + elif kwargs: + raise TypeError("Either list of fields or keywords" + " can be provided to NamedTuple, not both") + return _make_nmtuple(__typename, __fields, module=_caller()) + + NamedTuple.__doc__ = typing.NamedTuple.__doc__ + _NamedTuple = type.__new__(_NamedTupleMeta, 'NamedTuple', (), {}) + + # On 3.8+, alter the signature so that it matches typing.NamedTuple. + # The signature of typing.NamedTuple on >=3.8 is invalid syntax in Python 3.7, + # so just leave the signature as it is on 3.7. + if sys.version_info >= (3, 8): + NamedTuple.__text_signature__ = '(typename, fields=None, /, **kwargs)' + + def _namedtuple_mro_entries(bases): + assert NamedTuple in bases + return (_NamedTuple,) + + NamedTuple.__mro_entries__ = _namedtuple_mro_entries diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 00000000..658ae0a5 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,3 @@ +flake8 +flake8-bugbear +flake8-pyi