diff --git a/.gitignore b/.gitignore index 286b826..1aff8d0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ .pydevproject .idea .tox -.coverage +.coverage* .cache .eggs/ *.egg-info/ @@ -11,3 +11,6 @@ __pycache__/ docs/_build/ dist/ build/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..02f80cf --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + args: [ "--fix=lf" ] + - id: trailing-whitespace + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.7.2 + hooks: + - id: ruff + args: [--fix, --show-fixes] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 + hooks: + - id: mypy + additional_dependencies: [ "typing_extensions" ] + exclude: "^tests/" + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: rst-backticks + - id: rst-directive-colons + - id: rst-inline-touching-normal +ci: + autoupdate_schedule: quarterly diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..48ac4e1 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,16 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +sphinx: + configuration: docs/conf.py + fail_on_warning: true + +python: + install: + - method: pip + path: . + extra_requirements: [doc] diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 47f53cb..0000000 --- a/.travis.yml +++ /dev/null @@ -1,58 +0,0 @@ -language: python -sudo: false - -stages: - - name: test - - name: deploy to pypi - if: type = push AND tag =~ ^\d+\.\d+\.\d+ - -jobs: - fast_finish: true - include: - - env: TOXENV=flake8 - - - env: TOXENV=pypy3 - cache: pip - python: pypy3 - - - env: TOXENV=py34 - python: "3.4" - after_success: &after_success - - pip install coveralls - - coveralls - - - env: TOXENV=py35 - python: "3.5.0" - after_success: *after_success - - - env: TOXENV=py35 - python: "3.5.2" - after_success: *after_success - - - env: TOXENV=py36 - python: "3.6" - after_success: *after_success - - - env: TOXENV=py37 - python: "3.7" - dist: xenial - sudo: required - after_success: *after_success - - - stage: deploy to pypi - install: skip - script: skip - deploy: - provider: pypi - user: agronholm - password: - secure: gRSVobMY46ku8LMU/CkbhoawxDKZK0bQge2jBZwMt6UNK8X/Cu/mYHS7pihWRqGWacURLp7WZGeUB9ouHfGVIqlc8KQvdS4IgTHgo/CyZVG6AytyRPj+by9tmmYGh58J6DBstTD8c3h6pVytV4f0GcPJh+Cqfgfa6TKLF9dstxZELl5U4W46Po1Rk6Jk0GmhA7qKUd6/Y9fNRPntuEABFNGca8zTDinYTBzhQ6FbbuXfaF4FQkx3EvPm72ruagNkjCBkKXeqSr80Zxl0pPK5imW9VxhumnCm+DwStZ1dISQhEoJzK3b9GIllcFFWF4vUkTlEv9T+yZMhVyrJ+BBqPfKq1eNOALyWSVBTwWmjTez1AD0nNC4s5HegvKf2PF8F7y3EGUm+TLyKN3gC3LX14MHDJz4GJXY7n9gPW8syXU3npc1+bmaf1yfnR1BxncJQmru8nlmrpjG86w9qBH4BBSlkpTF7M82vcFKQ2w9BGZwoQ9vvzduMGRXCwgONfor+UPaRVarLyc+0j6HLXsC+EI9JN1PcDWF7WTj+PizYERB+U9PpjgniAKffGvhUUxjJvVnD9f/6CIKq4qjlTLv8C7PMwx1MVbE+p1JTxq530rQ55RMXicJCvhkn9mM0ytz/JUrDryOGDqmKiWVp53F+yLJHsUy8sN/zT3s6n8tf/Zg= - distributions: sdist bdist_wheel - on: - tags: true - -python: "3.4" - -install: pip install tox - -script: tox diff --git a/CHANGELOG.rst b/CHANGELOG.rst deleted file mode 100644 index 6687c1d..0000000 --- a/CHANGELOG.rst +++ /dev/null @@ -1,121 +0,0 @@ -Version history -=============== - -This library adheres to `Semantic Versioning `_. - -**2.2.2** (2018-08-13) - -- Fixed false positive when checking a callable against the plain ``typing.Callable`` on Python 3.7 - -**2.2.1** (2018-08-12) - -- Argument type annotations are no longer unioned with the types of their default values, except in - the case of ``None`` as the default value (although PEP 484 still recommends against this) -- Fixed some generic types (``typing.Collection`` among others) producing false negatives on - Python 3.7 -- Shortened unnecessarily long tracebacks by raising a new ``TypeError`` based on the old one -- Allowed type checking against arbitrary types by removing the requirement to supply a call memo - to ``check_type()`` -- Fixed ``AttributeError`` when running with the pydev debugger extension installed -- Fixed getting type names on ``typing.*`` on Python 3.7 (fix by Dale Jung) - -**2.2.0** (2018-07-08) - -- Fixed compatibility with Python 3.7 -- Removed support for Python 3.3 -- Added support for ``typing.NewType`` (contributed by reinhrst) - -**2.1.4** (2018-01-07) - -- Removed support for backports.typing, as it has been removed from PyPI -- Fixed checking of the numeric tower (complex -> float -> int) according to PEP 484 - -**2.1.3** (2017-03-13) - -- Fixed type checks against generic classes - -**2.1.2** (2017-03-12) - -- Fixed leak of function objects (should've used a ``WeakValueDictionary`` instead of - ``WeakKeyDictionary``) -- Fixed obscure failure of TypeChecker when it's unable to find the function object -- Fixed parametrized ``Type`` not working with type variables -- Fixed type checks against variable positional and keyword arguments - -**2.1.1** (2016-12-20) - -- Fixed formatting of README.rst so it renders properly on PyPI - -**2.1.0** (2016-12-17) - -- Added support for ``typings.Type`` (available in Python 3.5.2+) -- Added a third, ``sys.setprofile()`` based type checking approach (``typeguard.TypeChecker``) -- Changed certain type error messages to display "function" instead of the function's qualified - name - -**2.0.2** (2016-12-17) - -- More Python 3.6 compatibility fixes (along with a broader test suite) - -**2.0.1** (2016-12-10) - -- Fixed additional Python 3.6 compatibility issues - -**2.0.0** (2016-12-10) - -- **BACKWARD INCOMPATIBLE** Dropped Python 3.2 support -- Fixed incompatibility with Python 3.6 -- Use ``inspect.signature()`` in place of ``inspect.getfullargspec`` -- Added support for ``typing.NamedTuple`` - -**1.2.3** (2016-09-13) - -- Fixed ``@typechecked`` skipping the check of return value type when the type annotation was - ``None`` - -**1.2.2** (2016-08-23) - -- Fixed checking of homogenous Tuple declarations (``Tuple[bool, ...]``) - -**1.2.1** (2016-06-29) - -- Use ``backports.typing`` when possible to get new features on older Pythons -- Fixed incompatibility with Python 3.5.2 - -**1.2.0** (2016-05-21) - -- Fixed argument counting when a class is checked against a Callable specification -- Fixed argument counting when a functools.partial object is checked against a Callable - specification -- Added checks against mandatory keyword-only arguments when checking against a Callable - specification - -**1.1.3** (2016-05-09) - -- Gracefully exit if ``check_type_arguments`` can't find a reference to the current function - -**1.1.2** (2016-05-08) - -- Fixed TypeError when checking a builtin function against a parametrized Callable - -**1.1.1** (2016-01-03) - -- Fixed improper argument counting with bound methods when typechecking callables - -**1.1.0** (2016-01-02) - -- Eliminated the need to pass a reference to the currently executing function to - ``check_argument_types()`` - -**1.0.2** (2016-01-02) - -- Fixed types of default argument values not being considered as valid for the argument - -**1.0.1** (2016-01-01) - -- Fixed type hints retrieval being done for the wrong callable in cases where the callable was - wrapped with one or more decorators - -**1.0.0** (2015-12-28) - -- Initial release diff --git a/README.rst b/README.rst index 7c7242f..fe5896e 100644 --- a/README.rst +++ b/README.rst @@ -1,131 +1,46 @@ -.. image:: https://travis-ci.org/agronholm/typeguard.svg?branch=master - :target: https://travis-ci.org/agronholm/typeguard +.. image:: https://github.com/agronholm/typeguard/actions/workflows/test.yml/badge.svg + :target: https://github.com/agronholm/typeguard/actions/workflows/test.yml :alt: Build Status .. image:: https://coveralls.io/repos/agronholm/typeguard/badge.svg?branch=master&service=github :target: https://coveralls.io/github/agronholm/typeguard?branch=master :alt: Code Coverage +.. image:: https://readthedocs.org/projects/typeguard/badge/?version=latest + :target: https://typeguard.readthedocs.io/en/latest/?badge=latest + :alt: Documentation -This library provides run-time type checking for functions defined with argument type annotations. +This library provides run-time type checking for functions defined with +`PEP 484 `_ argument (and return) type +annotations, and any arbitrary objects. It can be used together with static type +checkers as an additional layer of type safety, to catch type violations that could only +be detected at run time. -The ``typing`` module introduced in Python 3.5 (and available on PyPI for older versions of -Python 3) is supported. See below for details. +Two principal ways to do type checking are provided: -There are three principal ways to use type checking, each with its pros and cons: +#. The ``check_type`` function: -#. calling ``check_argument_types()`` from within the function body: + * like ``isinstance()``, but supports arbitrary type annotations (within limits) + * can be used as a ``cast()`` replacement, but with actual checking of the value +#. Code instrumentation: - * debugger friendly (except when running with the pydev debugger with the C extension installed) - * cannot check the type of the return value - * does not work reliably with dynamically defined type hints (e.g. in nested functions) -#. decorating the function with ``@typechecked``: + * entire modules, or individual functions (via ``@typechecked``) are recompiled, with + type checking code injected into them + * automatically checks function arguments, return values and assignments to annotated + local variables + * for generator functions (regular and async), checks yield and send values + * requires the original source code of the instrumented module(s) to be accessible - * 100% reliable at finding the function object to be checked (does not need to check the garbage - collector) - * can check the type of the return value - * adds an extra frame to the call stack for every call to a decorated function -#. using ``with TypeChecker('packagename'):``: +Two options are provided for code instrumentation: - * emits warnings instead of raising ``TypeError`` - * eliminates boilerplate - * multiple TypeCheckers can be stacked/nested - * noninvasive (only records type violations; does not raise exceptions) - * does not work reliably with dynamically defined type hints (e.g. in nested functions) - * may cause problems with badly behaving debuggers or profilers +#. the ``@typechecked`` function: -If a function is called with incompatible argument types or a ``@typechecked`` decorated function -returns a value incompatible with the declared type, a descriptive ``TypeError`` exception is -raised. + * can be applied to functions individually +#. the import hook (``typeguard.install_import_hook()``): -Type checks can be fairly expensive so it is recommended to run Python in "optimized" mode -(``python -O`` or setting the ``PYTHONOPTIMIZE`` environment variable) when running code containing -type checks in production. The optimized mode will disable the type checks, by virtue of removing -all ``assert`` statements and setting the ``__debug__`` constant to ``False``. + * automatically instruments targeted modules on import + * no manual code changes required in the target modules + * requires the import hook to be installed before the targeted modules are imported + * may clash with other import hooks -Using ``check_argument_types()``: +See the documentation_ for further information. -.. code-block:: python3 - - from typeguard import check_argument_types - - def some_function(a: int, b: float, c: str, *args: str): - assert check_argument_types() - ... - -Using ``@typechecked``: - -.. code-block:: python3 - - from typeguard import typechecked - - @typechecked - def some_function(a: int, b: float, c: str, *args: str) -> bool: - ... - -To enable type checks even in optimized mode: - -.. code-block:: python3 - - @typechecked(always=True) - def foo(a: str, b: int, c: Union[str, int]) -> bool: - ... - -Using ``TypeChecker``: - -.. code-block:: python3 - - from warnings import filterwarnings - - from typeguard import TypeChecker, TypeWarning - - # Display all TypeWarnings, not just the first one - filterwarnings('always', category=TypeWarning) - - # Run your entire application inside this context block - with TypeChecker(['mypackage', 'otherpackage']): - mypackage.run_app() - - # Alternatively, manually start (and stop) the checker: - checker = TypeChecker('mypackage') - checker.start() - mypackage.start_app() - -.. hint:: Some other things you can do with ``TypeChecker``: - - * display all warnings from the start with ``python -W always::typeguard.TypeWarning`` - * redirect them to logging using ``logging.captureWarnings()`` - * record warnings in your pytest test suite and fail test(s) if you get any - (see the `pytest documentation `_ about that) - -To directly check a value against the specified type: - -.. code-block:: python3 - - from typeguard import check_type - - check_type('variablename', [1234], List[int]) - - -The following types from the ``typing`` package have specialized support: - -============== ============================================================ -Type Notes -============== ============================================================ -``Callable`` Argument count is checked but types are not (yet) -``Dict`` Keys and values are typechecked -``List`` Contents are typechecked -``NamedTuple`` Field values are typechecked -``Set`` Contents are typechecked -``Tuple`` Contents are typechecked -``Type`` -``TypeVar`` Constraints, bound types and co/contravariance are supported - but custom generic types are not (due to type erasure) -``Union`` -============== ============================================================ - - -Project links -------------- - -* `Change log `_ -* `Source repository `_ -* `Issue tracker `_ +.. _documentation: https://typeguard.readthedocs.io/en/latest/ diff --git a/debian/.gitignore b/debian/.gitignore new file mode 100644 index 0000000..2c8afeb --- /dev/null +++ b/debian/.gitignore @@ -0,0 +1 @@ +/files diff --git a/debian/changelog b/debian/changelog index 4564037..b313174 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,45 @@ +python-typeguard (4.4.1-1) unstable; urgency=medium + + * Team upload. + * New upstream release. + + -- Colin Watson Mon, 04 Nov 2024 12:55:28 +0000 + +python-typeguard (4.4.0-1) unstable; urgency=medium + + * Team upload. + * New upstream release. + + -- Colin Watson Tue, 29 Oct 2024 13:04:44 +0000 + +python-typeguard (4.3.0-1) unstable; urgency=medium + + * Team upload. + + [ Alexandre Detiste ] + * New upstream version (4.2.1). + + [ Colin Watson ] + * New upstream version (4.3.0): + - Fixed test suite incompatibility with pytest 8.2 (closes: #1073433). + * Switch to autopkgtest-pkg-pybuild. + * Standards-Version: 4.7.0 (no changes required). + + -- Colin Watson Tue, 30 Jul 2024 23:28:23 +0100 + +python-typeguard (4.1.5-1) unstable; urgency=medium + + * Team upload. + * New upstream version 4.1.5 + + [ Debian Janitor ] + * Bump debhelper from old 12 to 13. + * Set upstream metadata fields: Repository-Browse, + Bug-Database, Bug-Submit, Repository. + * Update standards version to 4.6.2, no changes needed. + + -- Alexandre Detiste Tue, 02 Jan 2024 22:13:00 +0100 + python-typeguard (2.2.2-2) unstable; urgency=medium * debian/control diff --git a/debian/control b/debian/control index 2fb6e1e..06f779c 100644 --- a/debian/control +++ b/debian/control @@ -3,21 +3,26 @@ Section: python Priority: optional Maintainer: Debian Python Team Uploaders: Joel Cross -Build-Depends: debhelper (>= 12), - debhelper-compat (= 12), - dh-python, - python3-all, - python3-setuptools, - python3-setuptools-scm -Standards-Version: 4.3.0 +Build-Depends: + debhelper-compat (= 13), + dh-sequence-python3, + python3-all, + pybuild-plugin-pyproject, + python3-pytest, + mypy, + python3-setuptools, + python3-setuptools-scm, + python3-typing-extensions (>= 4.10.0), +Rules-Requires-Root: no +Standards-Version: 4.7.0 Homepage: https://github.com/agronholm/typeguard Vcs-Git: https://salsa.debian.org/python-team/packages/python-typeguard.git Vcs-Browser: https://salsa.debian.org/python-team/packages/python-typeguard -Testsuite: autopkgtest-pkg-python +Testsuite: autopkgtest-pkg-pybuild Package: python3-typeguard Architecture: all -Depends: ${python3:Depends}, ${misc:Depends} +Depends: ${misc:Depends}, ${python3:Depends} Description: Run-time type checker for Python This library provides run-time type checking for functions defined with argument type annotations. This can be done in one of three ways: diff --git a/debian/rules b/debian/rules index 59551c2..0c7996b 100755 --- a/debian/rules +++ b/debian/rules @@ -1,44 +1,11 @@ #!/usr/bin/make -f -# See debhelper(7) (uncomment to enable) -# output every command that modifies files on the build system. + #export DH_VERBOSE = 1 export PYBUILD_NAME=typeguard %: - dh $@ --with python3 --buildsystem=pybuild - -# The following workaround is necessary because the author's name -# contains a special character on "setup.cfg". This triggers the -# following error: -# -# dh clean --with python3 --buildsystem=pybuild -# dh_auto_clean -O--buildsystem=pybuild -# I: pybuild base:217: python3.6 setup.py clean -# Traceback (most recent call last): -# File "setup.py", line 9, in -# 'setuptools_scm >= 1.7.0' -# File "/usr/lib/python3/dist-packages/setuptools/__init__.py", line 142, in setup -# _install_setup_requires(attrs) -# File "/usr/lib/python3/dist-packages/setuptools/__init__.py", line 135, in _install_setup_requires -# dist.parse_config_files(ignore_option_errors=True) -# File "/usr/lib/python3/dist-packages/setuptools/dist.py", line 564, in parse_config_files -# _Distribution.parse_config_files(self, filenames=filenames) -# File "/usr/lib/python3.6/distutils/dist.py", line 395, in parse_config_files -# parser.read(filename) -# File "/usr/lib/python3.6/configparser.py", line 697, in read -# self._read(fp, filename) -# File "/usr/lib/python3.6/configparser.py", line 1015, in _read -# for lineno, line in enumerate(fp, start=1): -# File "/usr/lib/python3.6/encodings/ascii.py", line 26, in decode -# return codecs.ascii_decode(input, self.errors)[0] -# UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 127: ordinal not in range(128) -# E: pybuild pybuild:338: clean: plugin distutils failed with: exit code=1: python3.6 setup.py clean -# dh_auto_clean: pybuild --clean -i python{version} -p "3.6 3.7" returned exit code 13 -# make: *** [debian/rules:9: clean] Error 25 -# gbp:error: '/usr/bin/git-pbuilder' failed: it exited with 2 -override_dh_auto_clean: - LC_ALL=C.UTF-8 dh_auto_clean + dh $@ --buildsystem=pybuild override_dh_installchangelogs: - dh_installchangelogs CHANGELOG.rst + dh_installchangelogs docs/versionhistory.rst diff --git a/debian/upstream/metadata b/debian/upstream/metadata new file mode 100644 index 0000000..a369d4f --- /dev/null +++ b/debian/upstream/metadata @@ -0,0 +1,5 @@ +--- +Bug-Database: https://github.com/agronholm/typeguard/issues +Bug-Submit: https://github.com/agronholm/typeguard/issues/new +Repository: https://github.com/agronholm/typeguard.git +Repository-Browse: https://github.com/agronholm/typeguard diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..d449ff7 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,77 @@ +API reference +============= + +.. module:: typeguard + +Type checking +------------- + +.. autofunction:: check_type + +.. autodecorator:: typechecked + +Import hook +----------- + +.. autofunction:: install_import_hook + +.. autoclass:: TypeguardFinder + :members: + +.. autoclass:: ImportHookManager + :members: + +Configuration +------------- + +.. data:: config + :type: TypeCheckConfiguration + + The global configuration object. + + Used by :func:`@typechecked <.typechecked>` and :func:`.install_import_hook`, and + notably **not used** by :func:`.check_type`. + +.. autoclass:: TypeCheckConfiguration + :members: + +.. autoclass:: CollectionCheckStrategy + +.. autoclass:: Unset + +.. autoclass:: ForwardRefPolicy + +.. autofunction:: warn_on_error + +Custom checkers +--------------- + +.. autofunction:: check_type_internal + +.. autofunction:: load_plugins + +.. data:: checker_lookup_functions + :type: list[Callable[[Any, Tuple[Any, ...], Tuple[Any, ...]], Optional[Callable[[Any, Any, Tuple[Any, ...], TypeCheckMemo], Any]]]] + + A list of callables that are used to look up a checker callable for an annotation. + +.. autoclass:: TypeCheckMemo + :members: + +Type check suppression +---------------------- + +.. autodecorator:: typeguard_ignore + +.. autofunction:: suppress_type_checks + +Exceptions and warnings +----------------------- + +.. autoexception:: InstrumentationWarning + +.. autoexception:: TypeCheckError + +.. autoexception:: TypeCheckWarning + +.. autoexception:: TypeHintWarning diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..59324be --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +from importlib.metadata import version as get_version + +from packaging.version import parse + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx_autodoc_typehints", +] + +templates_path = ["_templates"] +source_suffix = ".rst" +master_doc = "index" +project = "Typeguard" +author = "Alex Grönholm" +copyright = "2015, " + author + +v = parse(get_version("typeguard")) +version = v.base_version +release = v.public + +language = "en" + +exclude_patterns = ["_build"] +pygments_style = "sphinx" +autodoc_default_options = {"members": True} +autodoc_type_aliases = { + "TypeCheckerCallable": "typeguard.TypeCheckerCallable", + "TypeCheckFailCallback": "typeguard.TypeCheckFailCallback", + "TypeCheckLookupCallback": "typeguard.TypeCheckLookupCallback", +} +todo_include_todos = False + +html_theme = "sphinx_rtd_theme" +htmlhelp_basename = "typeguarddoc" + +intersphinx_mapping = {"python": ("https://docs.python.org/3/", None)} diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..9140189 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,86 @@ +Contributing to Typeguard +========================= + +.. highlight:: bash + +If you wish to contribute a fix or feature to Typeguard, please follow the following +guidelines. + +When you make a pull request against the main Typeguard codebase, Github runs the test +suite against your modified code. Before making a pull request, you should ensure that +the modified code passes tests and code quality checks locally. + +Running the test suite +---------------------- + +You can run the test suite two ways: either with tox_, or by running pytest_ directly. + +To run tox_ against all supported (of those present on your system) Python versions:: + + tox + +Tox will handle the installation of dependencies in separate virtual environments. + +To pass arguments to the underlying pytest_ command, you can add them after ``--``, like +this:: + + tox -- -k somekeyword + +To use pytest directly, you can set up a virtual environment and install the project in +development mode along with its test dependencies (virtualenv activation demonstrated +for Linux and macOS; on Windows you need ``venv\Scripts\activate`` instead):: + + python -m venv venv + source venv/bin/activate + pip install -e .[test] + +Now you can just run pytest_:: + + pytest + +Building the documentation +-------------------------- + +To build the documentation, run ``tox -e docs``. This will place the documentation in +``build/sphinx/html`` where you can open ``index.html`` to view the formatted +documentation. + +Typeguard uses ReadTheDocs_ to automatically build the documentation so the above +procedure is only necessary if you are modifying the documentation and wish to check the +results before committing. + +Typeguard uses pre-commit_ to perform several code style/quality checks. It is +recommended to activate pre-commit_ on your local clone of the repository (using +``pre-commit install``) to ensure that your changes will pass the same checks on GitHub. + +Making a pull request on Github +------------------------------- + +To get your changes merged to the main codebase, you need a Github account. + +#. Fork the repository (if you don't have your own fork of it yet) by navigating to the + `main Typeguard repository`_ and clicking on "Fork" near the top right corner. +#. Clone the forked repository to your local machine with + ``git clone git@github.com/yourusername/typeguard``. +#. Create a branch for your pull request, like ``git checkout -b myfixname`` +#. Make the desired changes to the code base. +#. Commit your changes locally. If your changes close an existing issue, add the text + ``Fixes #XXX.`` or ``Closes #XXX.`` to the commit message (where XXX is the issue + number). +#. Push the changeset(s) to your forked repository (``git push``) +#. Navigate to Pull requests page on the original repository (not your fork) and click + "New pull request" +#. Click on the text "compare across forks". +#. Select your own fork as the head repository and then select the correct branch name. +#. Click on "Create pull request". + +If you have trouble, consult the `pull request making guide`_ on opensource.com. + +.. _Docker: https://docs.docker.com/desktop/#download-and-install +.. _docker compose: https://docs.docker.com/compose/ +.. _tox: https://tox.readthedocs.io/en/latest/install.html +.. _pre-commit: https://pre-commit.com/#installation +.. _pytest: https://pypi.org/project/pytest/ +.. _ReadTheDocs: https://readthedocs.org/ +.. _main Typeguard repository: https://github.com/agronholm/typeguard +.. _pull request making guide: https://opensource.com/article/19/7/create-pull-request-github diff --git a/docs/extending.rst b/docs/extending.rst new file mode 100644 index 0000000..4b9f888 --- /dev/null +++ b/docs/extending.rst @@ -0,0 +1,113 @@ +Extending Typeguard +=================== + +.. py:currentmodule:: typeguard + +Adding new type checkers +------------------------ + +The range of types supported by Typeguard can be extended by writing a +**type checker lookup function** and one or more **type checker functions**. The former +will return one of the latter, or ``None`` if the given value does not match any of your +custom type checker functions. + +The lookup function receives three arguments: + +#. The origin type (the annotation with any arguments stripped from it) +#. The previously stripped out generic arguments, if any +#. Extra arguments from the :class:`~typing.Annotated` annotation, if any + +For example, if the annotation was ``tuple``,, the lookup function would be called with +``tuple, (), ()``. If the type was parametrized, like ``tuple[str, int]``, it would be +called with ``tuple, (str, int), ()``. If the annotation was +``Annotated[tuple[str, int], "foo", "bar"]``, the arguments would instead be +``tuple, (str, int), ("foo", "bar")``. + +The checker function receives four arguments: + +#. The value to be type checked +#. The origin type +#. The generic arguments from the annotation (empty tuple when the annotation was not + parametrized) +#. The memo object (:class:`~.TypeCheckMemo`) + +There are a couple of things to take into account when writing a type checker: + +#. If your type checker function needs to do further type checks (such as type checking + items in a collection), you need to use :func:`~.check_type_internal` (and pass + along ``memo`` to it) +#. If you're type checking collections, your checker function should respect the + :attr:`~.TypeCheckConfiguration.collection_check_strategy` setting, available from + :attr:`~.TypeCheckMemo.config` + +.. versionchanged:: 4.0 + In Typeguard 4.0, checker functions **must** respect the settings in + ``memo.config``, rather than the global configuration + +The following example contains a lookup function and type checker for a custom class +(``MySpecialType``):: + + from __future__ import annotations + from inspect import isclass + from typing import Any + + from typeguard import TypeCheckError, TypeCheckerCallable, TypeCheckMemo + + + class MySpecialType: + pass + + + def check_my_special_type( + value: Any, origin_type: Any, args: tuple[Any, ...], memo: TypeCheckMemo + ) -> None: + if not isinstance(value, MySpecialType): + raise TypeCheckError('is not my special type') + + + def my_checker_lookup( + origin_type: Any, args: tuple[Any, ...], extras: tuple[Any, ...] + ) -> TypeCheckerCallable | None: + if isclass(origin_type) and issubclass(origin_type, MySpecialType): + return check_my_special_type + + return None + +Registering your type checker lookup function with Typeguard +------------------------------------------------------------ + +Just writing a type checker lookup function doesn't do anything by itself. You'll have +to advertise your type checker lookup function to Typeguard somehow. There are two ways +to do that (pick just one): + +#. Append to :data:`typeguard.checker_lookup_functions` +#. Add an `entry point`_ to your project in the ``typeguard.checker_lookup`` group + +If you're packaging your project with standard packaging tools, it may be better to add +an entry point instead of registering it manually, because manual registration requires +the registration code to run first before the lookup function can work. + +To manually register the type checker lookup function with Typeguard:: + + from typeguard import checker_lookup_functions + + checker_lookup_functions.append(my_checker_lookup) + +For adding entry points to your project packaging metadata, the exact method may vary +depending on your packaging tool of choice, but the standard way (supported at least by +recent versions of ``setuptools``) is to add this to ``pyproject.toml``: + +.. code-block:: toml + + [project.entry-points] + typeguard.checker_lookup = {myplugin = "myapp.my_plugin_module:my_checker_lookup"} + +The configuration above assumes that the **globally unique** (within the +``typeguard.checker_lookup`` namespace) entry point name for your lookup function is +``myplugin``, it lives in the ``myapp.my_plugin_module`` and the name of the function +there is ``my_checker_lookup``. + +.. note:: After modifying your project configuration, you may have to reinstall it in + order for the entry point to become discoverable. + +.. _entry point: https://docs.python.org/3/library/importlib.metadata.html#entry-points diff --git a/docs/features.rst b/docs/features.rst new file mode 100644 index 0000000..3141456 --- /dev/null +++ b/docs/features.rst @@ -0,0 +1,225 @@ +Features +========= + +.. py:currentmodule:: typeguard + +What does Typeguard check? +-------------------------- + +The following type checks are implemented in Typeguard: + +* Types of arguments passed to instrumented functions +* Types of values returned from instrumented functions +* Types of values yielded from instrumented generator functions +* Types of values sent to instrumented generator functions +* Types of values assigned to local variables within instrumented functions + +What does Typeguard NOT check? +------------------------------ + +The following type checks are not yet supported in Typeguard: + +* Types of values assigned to class or instance variables +* Types of values assigned to global or nonlocal variables +* Stubs defined with :func:`@overload ` (the implementation is checked + if instrumented) +* ``yield from`` statements in generator functions +* ``ParamSpec`` and ``Concatenate`` are currently ignored +* Types where they are shadowed by arguments with the same name (e.g. + ``def foo(x: type, type: str): ...``) + +Other limitations +----------------- + +Local references to nested classes +++++++++++++++++++++++++++++++++++ + +Forward references from methods pointing to non-local nested classes cannot currently be +resolved:: + + class Outer: + class Inner: + pass + + # Cannot be resolved as the name is no longer available + def method(self) -> "Inner": + return Outer.Inner() + +This shortcoming may be resolved in a future release. + +Using :func:`@typechecked ` on top of other decorators ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +As :func:`@typechecked ` works by recompiling the target function with +instrumentation added, it needs to replace all the references to the original function +with the new one. This could be impossible when it's placed on top of another decorator +that wraps the original function. It has no way of telling that other decorator that the +target function should be switched to a new one. To work around this limitation, either +place :func:`@typechecked ` at the bottom of the decorator stack, or use +the import hook instead. + +Protocol checking ++++++++++++++++++ + +As of version 4.3.0, Typeguard can check instances and classes against Protocols, +regardless of whether they were annotated with +:func:`@runtime_checkable `. + +The only current limitation is that argument annotations are not checked for +compatibility, however this should be covered by static type checkers pretty well. + +Special considerations for ``if TYPE_CHECKING:`` +------------------------------------------------ + +Both the import hook and :func:`@typechecked ` avoid checking against +anything imported in a module-level ``if TYPE_CHECKING:`` (or +``if typing.TYPE_CHECKING:``) block, since those types will not be available at run +time. Therefore, no errors or warnings are emitted for such annotations, even when they +would normally not be found. + +Support for generator functions +------------------------------- + +For generator functions, the checks applied depend on the function's return annotation. +For example, the following function gets its yield, send and return values type +checked:: + + from collections.abc import Generator + + def my_generator() -> Generator[int, str, bool]: + a = yield 6 + return True + +In contrast, the following generator function only gets its yield value checked:: + + from collections.abc import Iterator + + def my_generator() -> Iterator[int]: + a = yield 6 + return True + +Asynchronous generators work just the same way, except they don't support returning +values other than ``None``, so the annotation only has two items:: + + from collections.abc import AsyncGenerator + + async def my_generator() -> AsyncGenerator[int, str]: + a = yield 6 + +Overall, the following type annotations will work for generator function type checking: + +* :class:`typing.Generator` +* :class:`collections.abc.Generator` +* :class:`typing.Iterator` +* :class:`collections.abc.Iterator` +* :class:`typing.Iterable` +* :class:`collections.abc.Iterable` +* :class:`typing.AsyncIterator` +* :class:`collections.abc.AsyncIterator` +* :class:`typing.AsyncIterable` +* :class:`collections.abc.AsyncIterable` +* :class:`typing.AsyncGenerator` +* :class:`collections.abc.AsyncGenerator` + +Support for PEP 604 unions on Pythons older than 3.10 +----------------------------------------------------- + +The :pep:`604` ``X | Y`` notation was introduced in Python 3.10, but it can be used with +older Python versions in modules where ``from __future__ import annotations`` is +present. Typeguard contains a special parser that lets it convert these to older +:class:`~typing.Union` annotations internally. + +Support for generic built-in collection types on Pythons older than 3.9 +----------------------------------------------------------------------- + +The built-in collection types (:class:`list`, :class:`tuple`, :class:`dict`, +:class:`set` and :class:`frozenset`) gained support for generics in Python 3.9. +For earlier Python versions, Typeguard provides a way to work with such annotations by +substituting them with the equivalent :mod:`typing` types. The only requirement for this +to work is the use of ``from __future__ import annotations`` in all such modules. + +Support for mock objects +------------------------ + +Typeguard handles the :class:`unittest.mock.Mock` class (and its subclasses) specially, +bypassing any type checks when encountering instances of these classes. Note that any +"spec" class passed to the mock object is currently not respected. + +Supported standard library annotations +-------------------------------------- + +The following types from the standard library have specialized support: + +.. list-table:: + :header-rows: 1 + + * - Type(s) + - Notes + * - :class:`typing.Any` + - Any type passes type checks against this annotation. Inheriting from ``Any`` + (:class:`typing.Any` on Python 3.11+, or ``typing.extensions.Any``) will pass any + type check + * - :class:`typing.Annotated` + - Original annotation is unwrapped and typechecked normally + * - :class:`BinaryIO` + - Specialized instance checks are performed + * - | :class:`typing.Callable` + | :class:`collections.abc.Callable` + - Argument count is checked but types are not (yet) + * - | :class:`dict` + | :class:`typing.Dict` + - Keys and values are typechecked + * - :class:`typing.IO` + - Specialized instance checks are performed + * - | :class:`list` + | :class:`typing.List` + - Contents are typechecked + * - :class:`typing.Literal` + - + * - :class:`typing.LiteralString` + - Checked as :class:`str` + * - | :class:`typing.Mapping` + | :class:`typing.MutableMapping` + | :class:`collections.abc.Mapping` + | :class:`collections.abc.MutableMapping` + - Keys and values are typechecked + * - :class:`typing.NamedTuple` + - Field values are typechecked + * - | :class:`typing.Never` + | :class:`typing.NoReturn` + - Supported in argument and return type annotations + * - :class:`typing.Protocol` + - Run-time protocols are checked with :func:`isinstance`, others are ignored + * - :class:`typing.Self` + - + * - | :class:`set` + | :class:`frozenset` + | :class:`typing.Set` + | :class:`typing.AbstractSet` + - Contents are typechecked + * - | :class:`typing.Sequence` + | :class:`collections.abc.Sequence` + - Contents are typechecked + * - :class:`typing.TextIO` + - Specialized instance checks are performed + * - | :class:`tuple` + | :class:`typing.Tuple` + - Contents are typechecked + * - | :class:`type` + | :class:`typing.Type` + - + * - :class:`typing.TypeGuard` + - Checked as :class:`bool` + * - :class:`typing.TypedDict` + - Contents are typechecked; On Python 3.8 and earlier, ``total`` from superclasses + is not respected (see `#101`_ for more information); On Python 3.9.0, false + positives can happen when constructing :class:`typing.TypedDict` classes using + old-style syntax (see `issue 42059`_) + * - :class:`typing.TypeVar` + - Constraints and bound types are typechecked + * - :class:`typing.Union` + - :pep:`604` unions are supported on all Python versions when + ``from __future__ import annotations`` is used + +.. _#101: https://github.com/agronholm/typeguard/issues/101 +.. _issue 42059: https://bugs.python.org/issue42059 diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..e7a4e29 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,18 @@ +Typeguard +========= + +.. include:: ../README.rst + :end-before: See the + +Quick links +----------- + +.. toctree:: + :maxdepth: 1 + + userguide + features + extending + contributing + api + versionhistory diff --git a/docs/userguide.rst b/docs/userguide.rst new file mode 100644 index 0000000..fa2dc16 --- /dev/null +++ b/docs/userguide.rst @@ -0,0 +1,280 @@ +User guide +========== + +.. py:currentmodule:: typeguard + +Checking types directly +----------------------- + +The most straightfoward way to do type checking with Typeguard is with +:func:`.check_type`. It can be used as as a beefed-up version of :func:`isinstance` that +also supports checking against annotations in the :mod:`typing` module:: + + from typeguard import check_type + + # Raises TypeCheckError if there's a problem + check_type([1234], List[int]) + +It's also useful for safely casting the types of objects dynamically constructed from +external sources:: + + import json + from typing import List, TypedDict + + from typeguard import check_type + + # Example contents of "people.json": + # [ + # {"name": "John Smith", "phone": "111-123123", "address": "123 Main Street"}, + # {"name": "Jane Smith", "phone": "111-456456", "address": "123 Main Street"} + # ] + + class Person(TypedDict): + name: str + phone: str + address: str + + with open("people.json") as f: + people = check_type(json.load(f), List[Person]) + +With this code, static type checkers will recognize the type of ``people`` to be +``List[Person]``. + +Using the decorator +------------------- + +The :func:`@typechecked ` decorator is the simplest way to add type +checking on a case-by-case basis. It can be used on functions directly, or on entire +classes, in which case all the contained methods are instrumented:: + + from typeguard import typechecked + + @typechecked + def some_function(a: int, b: float, c: str, *args: str) -> bool: + ... + return retval + + @typechecked + class SomeClass: + # All type annotated methods (including static and class methods and properties) + # are type checked. + # Does not apply to inner classes! + def method(x: int) -> int: + ... + +The decorator instruments functions by fetching the source code, parsing it to an +abstract syntax tree using :func:`ast.parse`, modifying it to add type checking, and +finally compiling the modified AST into byte code. This code is then used to make a new +function object that is used to replace the original one. + +To explicitly set type checking options on a per-function basis, you can pass them as +keyword arguments to :func:`@typechecked `:: + + from typeguard import CollectionCheckStrategy, typechecked + + @typechecked(collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS) + def some_function(a: int, b: float, c: str, *args: str) -> bool: + ... + return retval + +This also allows you to override the global options for specific functions when using +the import hook. + +.. note:: You should always place this decorator closest to the original function, + as it will not work when there is another decorator wrapping the function. + For the same reason, when you use it on a class that has wrapping decorators on + its methods, such methods will not be instrumented. In contrast, the import hook + has no such restrictions. + +Using the import hook +--------------------- + +The import hook, when active, automatically instruments all type annotated functions to +type check arguments, return values and values yielded by or sent to generator +functions. This allows for a non-invasive method of run time type checking. This method +does not modify the source code on disk, but instead modifies its AST (Abstract Syntax +Tree) when the module is loaded. + +Using the import hook is as straightforward as installing it before you import any +modules you wish to be type checked. Give it the name of your top level package (or a +list of package names):: + + from typeguard import install_import_hook + + install_import_hook('myapp') + from myapp import some_module # import only AFTER installing the hook, or it won't take effect + +If you wish, you can uninstall the import hook:: + + manager = install_import_hook('myapp') + from myapp import some_module + manager.uninstall() + +or using the context manager approach:: + + with install_import_hook('myapp'): + from myapp import some_module + +You can also customize the logic used to select which modules to instrument:: + + from typeguard import TypeguardFinder, install_import_hook + + class CustomFinder(TypeguardFinder): + def should_instrument(self, module_name: str): + # disregard the module names list and instrument all loaded modules + return True + + install_import_hook('', cls=CustomFinder) + +.. _forwardrefs: + +Notes on forward reference handling +----------------------------------- + +The internal type checking functions, injected to instrumented code by either +:func:`@typechecked ` or the import hook, use the "naked" versions of any +annotations, undoing any quotations in them (and the effects of +``from __future__ import annotations``). As such, in instrumented code, the +:attr:`~.TypeCheckConfiguration.forward_ref_policy` only applies when using type +variables containing forward references, or type aliases likewise containing forward +references. + +To facilitate the use of types only available to static type checkers, Typeguard +recognizes module-level imports guarded by ``if typing.TYPE_CHECKING:`` or +``if TYPE_CHECKING:`` (add the appropriate :mod:`typing` imports). Imports made within +such blocks on the module level will be replaced in calls to internal type checking +functions with :data:`~typing.Any`. + +Using the pytest plugin +----------------------- + +Typeguard comes with a plugin for pytest (v7.0 or newer) that installs the import hook +(explained in the previous section). To use it, run ``pytest`` with the appropriate +``--typeguard-packages`` option. For example, if you wanted to instrument the +``foo.bar`` and ``xyz`` packages for type checking, you can do the following: + +.. code-block:: bash + + pytest --typeguard-packages=foo.bar,xyz + +It is also possible to set option for the pytest plugin using pytest's own +configuration. For example, here's how you might specify several options in +``pyproject.toml``: + +.. code-block:: toml + + [tool.pytest.ini_options] + typeguard-packages = """ + foo.bar + xyz""" + typeguard-debug-instrumentation = true + typeguard-typecheck-fail-callback = "mypackage:failcallback" + typeguard-forward-ref-policy = "ERROR" + typeguard-collection-check-strategy = "ALL_ITEMS" + +See the next section for details on how the individual options work. + +.. note:: There is currently no support for specifying a customized module finder. + +Setting configuration options +----------------------------- + +There are several configuration options that can be set that influence how type checking +is done. The :data:`typeguard.config` (which is of type +:class:`~.TypeCheckConfiguration`) controls the options applied to code instrumented via +either :func:`@typechecked <.typechecked>` or the import hook. The +:func:`~.check_type`, function, however, uses the built-in defaults and is not affected +by the global configuration, so you must pass any configuration overrides explicitly +with each call. + +You can also override specific configuration options in instrumented functions (or +entire classes) by passing keyword arguments to :func:`@typechecked <.typechecked>`. +You can do this even if you're using the import hook, as the import hook will remove the +decorator to ensure that no double instrumentation takes place. If you're using the +import hook to type check your code only during tests and don't want to include +``typeguard`` as a run-time dependency, you can use a dummy replacement for the +decorator. + +For example, the following snippet will only import the decorator during a pytest_ run:: + + import sys + + if "pytest" in sys.modules: + from typeguard import typechecked + else: + from typing import TypeVar + _T = TypeVar("_T") + + def typechecked(target: _T, **kwargs) -> _T: + return target if target else typechecked + +.. _pytest: https://docs.pytest.org/ + +Suppressing type checks +----------------------- + +Temporarily disabling type checks ++++++++++++++++++++++++++++++++++ + +If you need to temporarily suppress type checking, you can use the +:func:`~.suppress_type_checks` function, either as a context manager or a decorator, to +skip the checks:: + + from typeguard import check_type, suppress_type_checks + + with suppress_type_checks(): + check_type(1, str) # would fail without the suppression + + @suppress_type_checks + def my_suppressed_function(x: int) -> None: + ... + +Suppression state is tracked globally. Suppression ends only when all the context +managers have exited and all calls to decorated functions have returned. + +Permanently suppressing type checks for selected functions +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +To exclude specific functions from run time type checking, you can use one of the +following decorators: + + * :func:`@typeguard_ignore `: prevents the decorated + function from being instrumentated by the import hook + * :func:`@no_type_check `: as above, but disables static type + checking too + +For example, calling the function defined below will not result in a type check error +when the containing module is instrumented by the import hook:: + + from typeguard import typeguard_ignore + + @typeguard_ignore + def f(x: int) -> int: + return str(x) + +.. warning:: The :func:`@no_type_check_decorator ` + decorator is not currently recognized by Typeguard. + +Suppressing the ``@typechecked`` decorator in production +-------------------------------------------------------- + +If you're using the :func:`@typechecked ` decorator to gradually introduce +run-time type checks to your code base, you can disable the checks in production by +running Python in optimized mode (as opposed to debug mode which is the default mode). +You can do this by either starting Python with the ``-O`` or ``-OO`` option, or by +setting the PYTHONOPTIMIZE_ environment variable. This will cause +:func:`@typechecked ` to become a no-op when the import hook is not being +used to instrument the code. + +.. _PYTHONOPTIMIZE: https://docs.python.org/3/using/cmdline.html#envvar-PYTHONOPTIMIZE + +Debugging instrumented code +--------------------------- + +If you find that your code behaves in an unexpected fashion with the Typeguard +instrumentation in place, you should set the ``typeguard.config.debug_instrumentation`` +flag to ``True``. This will print all the instrumented code after the modifications, +which you can check to find the reason for the unexpected behavior. + +If you're using the pytest plugin, you can also pass the +``--typeguard-debug-instrumentation`` and ``-s`` flags together for the same effect. diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst new file mode 100644 index 0000000..45880a2 --- /dev/null +++ b/docs/versionhistory.rst @@ -0,0 +1,575 @@ +Version history +=============== + +This library adheres to +`Semantic Versioning 2.0 `_. + +**4.4.1** (2024-11-03) + +- Dropped Python 3.8 support +- Changed the signature of ``typeguard_ignore()`` to be compatible with + ``typing.no_type_check()`` (PR by @jolaf) +- Avoid creating reference cycles when type checking uniontypes and classes +- Fixed checking of variable assignments involving tuple unpacking + (`#486 `_) +- Fixed ``TypeError`` when checking a class against ``type[Self]`` + (`#481 `_) +- Fixed checking of protocols on the class level (against ``type[SomeProtocol]``) + (`#498 `_) +- Fixed ``Self`` checks in instance/class methods that have positional-only arguments +- Fixed explicit checks of PEP 604 unions against ``types.UnionType`` + (`#467 `_) +- Fixed checks against annotations wrapped in ``NotRequired`` not being run unless the + ``NotRequired`` is a forward reference + (`#454 `_) +- Fixed the ``pytest_ignore_collect`` hook in the pytest plugin blocking default pytest + collection ignoring behavior by returning ``None`` instead of ``False`` + (PR by @mgorny) + +**4.4.0** (2024-10-27) + +- Added proper checking for method signatures in protocol checks + (`#465 `_) +- Fixed basic support for intersection protocols + (`#490 `_; PR by @antonagestam) +- Fixed protocol checks running against the class of an instance and not the instance + itself (this produced wrong results for non-method member checks) + +**4.3.0** (2024-05-27) + +- Added support for checking against static protocols +- Fixed some compatibility problems when running on Python 3.13 + (`#460 `_; PR by @JelleZijlstra) +- Fixed test suite incompatibility with pytest 8.2 + (`#461 `_) +- Fixed pytest plugin crashing on pytest version older than v7.0.0 (even if it's just + present) (`#343 `_) + +**4.2.1** (2023-03-24) + +- Fixed missing ``typing_extensions`` dependency for Python 3.12 + (`#444 `_) +- Fixed deprecation warning in the test suite on Python 3.13 + (`#444 `_) + +**4.2.0** (2023-03-23) + +- Added support for specifying options for the pytest plugin via pytest config files + (`#440 `_) +- Avoid creating reference cycles when type checking unions (PR by Shantanu) +- Fixed ``Optional[...]`` being removed from the AST if it was located within a + subscript (`#442 `_) +- Fixed ``TypedDict`` from ``typing_extensions`` not being recognized as one + (`#443 `_) +- Fixed ``typing`` types (``dict[str, int]``, ``List[str]``, etc.) not passing checks + against ``type`` or ``Type`` + (`#432 `_, PR by Yongxin Wang) +- Fixed detection of optional fields (``NotRequired[...]``) in ``TypedDict`` when using + forward references (`#424 `_) +- Fixed mapping checks against Django's ``MultiValueDict`` + (`#419 `_) + +**4.1.5** (2023-09-11) + +- Fixed ``Callable`` erroneously rejecting a callable that has the requested amount of + positional arguments but they have defaults + (`#400 `_) +- Fixed a regression introduced in v4.1.4 where the elements of ``Literal`` got quotes + removed from them by the AST transformer + (`#399 `_) + +**4.1.4** (2023-09-10) + +- Fixed ``AttributeError`` where the transformer removed elements from a PEP 604 union + (`#384 `_) +- Fixed ``AttributeError: 'Subscript' object has no attribute 'slice'`` when + encountering an annotation with a subscript containing an ignored type (imported + within an ``if TYPE_CHECKING:`` block) + (`#397 `_) +- Fixed type checking not being skipped when the target is a union (PEP 604 or + ``typing.Union``) where one of the elements is an ignored type (shadowed by an + argument, variable assignment or an ``if TYPE_CHECKING`` import) + (`#394 `_, + `#395 `_) +- Fixed type checking of class instances created in ``__new__()`` in cases such as enums + where this method is already invoked before the class has finished initializing + (`#398 `_) + +**4.1.3** (2023-08-27) + +- Dropped Python 3.7 support +- Fixed ``@typechecked`` optimization causing compilation of instrumented code to fail + when any block was left empty by the AST transformer (eg ``if`` or + ``try`` / ``except`` blocks) + (`#352 `_) +- Fixed placement of injected typeguard imports with respect to ``__future__`` imports + and module docstrings (`#385 `_) + +**4.1.2** (2023-08-18) + +- Fixed ``Any`` being removed from a subscript that still contains other elements + (`#373 `_) + +**4.1.1** (2023-08-16) + +- Fixed ``suppress_type_checks()`` causing annotated variable assignments to always + assign ``None`` (`#380 `_) + +**4.1.0** (2023-07-30) + +- Added support for passing a tuple as ``expected_type`` to ``check_type()``, making it + more of a drop-in replacement for ``isinstance()`` + (`#371 `_) +- Fixed regression where ``Literal`` inside a ``Union`` had quotes stripped from its + contents, thus typically causing ``NameError`` to be raised when run + (`#372 `_) + +**4.0.1** (2023-07-27) + +- Fixed handling of ``typing_extensions.Literal`` on Python 3.8 and 3.9 when + ``typing_extensions>=4.6.0`` is installed + (`#363 `_; PR by Alex Waygood) +- Fixed ``NameError`` when generated type checking code references an imported name from + a method (`#362 `_) +- Fixed docstrings disappearing from instrumented functions + (`#359 `_) +- Fixed ``@typechecked`` failing to instrument functions when there are more than one + function within the same scope + (`#355 `_) +- Fixed ``frozenset`` not being checked + (`#367 `_) + +**4.0.0** (2023-05-12) + +- No changes + +**4.0.0rc6** (2023-05-07) + +- Fixed ``@typechecked`` optimization causing compilation of instrumented code to fail + when an ``if`` block was left empty by the AST transformer + (`#352 `_) +- Fixed the AST transformer trying to parse the second argument of ``typing.Annotated`` + as a forward reference (`#353 `_) + +**4.0.0rc5** (2023-05-01) + +- Added ``InstrumentationWarning`` to the public API +- Changed ``@typechecked`` to skip instrumentation in optimized mode, as in typeguard + 2.x +- Avoid type checks where the types in question are shadowed by local variables +- Fixed instrumentation using ``typing.Optional`` without a subscript when the subscript + value was erased due to being an ignored import +- Fixed ``TypeError: isinstance() arg 2 must be a type or tuple of types`` when + instrumented code tries to check a value against a naked (``str``, not ``ForwardRef``) + forward reference +- Fixed instrumentation using the wrong "self" type in the ``__new__()`` method + +**4.0.0rc4** (2023-04-15) + +- Fixed imports guarded by ``if TYPE_CHECKING:`` when used with subscripts + (``SomeType[...]``) being replaced with ``Any[...]`` instead of just ``Any`` +- Fixed instrumentation inadvertently mutating a function's annotations on Python 3.7 + and 3.8 +- Fixed ``Concatenate[...]`` in ``Callable`` parameters causing ``TypeError`` to be + raised +- Fixed type checks for ``*args`` or ``**kwargs`` not being suppressed when their types + are unusable (guarded by ``if TYPE_CHECKING:`` or otherwise) +- Fixed ``TypeError`` when checking against a generic ``NewType`` +- Don't try to check types shadowed by argument names (e.g. + ``def foo(x: type, type: str): ...``) +- Don't check against unions where one of the elements is ``Any`` + +**4.0.0rc3** (2023-04-10) + +- Fixed ``typing.Literal`` subscript contents being evaluated as forward references +- Fixed resolution of forward references in type aliases + +**4.0.0rc2** (2023-04-08) + +- The ``.pyc`` files now use a version-based optimization suffix in the file names so as + not to cause the interpreter to load potentially faulty/incompatible cached bytecode + generated by older versions +- Fixed typed variable positional and keyword arguments causing compilation errors on + Python 3.7 and 3.8 +- Fixed compilation error when a type annotation contains a type guarded by + ``if TYPE_CHECKING:`` + +**4.0.0rc1** (2023-04-02) + +- **BACKWARD INCOMPATIBLE** ``check_type()`` no longer uses the global configuration. + It now uses the default configuration values, unless overridden with an explicit + ``config`` argument. +- **BACKWARD INCOMPATIBLE** Removed ``CallMemo`` from the API +- **BACKWARD INCOMPATIBLE** Required checkers to use the configuration from + ``memo.config``, rather than the global configuration +- Added keyword arguments to ``@typechecked``, allowing users to override settings on a + per-function basis +- Added support for using ``suppress_type_checks()`` as a decorator +- Added support for type checking against nonlocal classes defined within the same + parent function as the instrumented function +- Changed instrumentation to statically copy the function annotations to avoid having to + look up the function object at run time +- Improved support for avoiding type checks against imports declared in + ``if TYPE_CHECKING:`` blocks +- Fixed ``check_type`` not returning the passed value when checking against ``Any``, or + when type checking is being suppressed +- Fixed ``suppress_type_checks()`` not ending the suppression if the context block + raises an exception +- Fixed checking non-dictionary objects against a ``TypedDict`` annotation + (PR by Tolker-KU) + +**3.0.2** (2023-03-22) + +- Improved warnings by ensuring that they target user code and not Typeguard internal + code +- Fixed ``warn_on_error()`` not showing where the type violation actually occurred +- Fixed local assignment to ``*args`` or ``**kwargs`` being type checked incorrectly +- Fixed ``TypeError`` on ``check_type(..., None)`` +- Fixed unpacking assignment not working with a starred variable (``x, *y = ...``) in + the target tuple +- Fixed variable multi-assignment (``a = b = c = ...``) being type checked incorrectly + +**3.0.1** (2023-03-16) + +- Improved the documentation +- Fixed assignment unpacking (``a, b = ...``) being checked incorrectly +- Fixed ``@typechecked`` attempting to instrument wrapper decorators such as + ``@contextmanager`` when applied to a class +- Fixed ``py.typed`` missing from the wheel when not building from a git checkout + +**3.0.0** (2023-03-15) + +- **BACKWARD INCOMPATIBLE** Dropped the ``argname``, ``memo``, ``globals`` and + ``locals`` arguments from ``check_type()`` +- **BACKWARD INCOMPATIBLE** Removed the ``check_argument_types()`` and + ``check_return_type()`` functions (use ``@typechecked`` instead) +- **BACKWARD INCOMPATIBLE** Moved ``install_import_hook`` to be directly importable + from the ``typeguard`` module +- **BACKWARD INCOMPATIBLE** Changed the checking of collections (list, set, dict, + sequence, mapping) to only check the first item by default. To get the old behavior, + set ``typeguard.config.collection_check_strategy`` to + ``CollectionCheckStrategy.ALL_ITEMS`` +- **BACKWARD INCOMPATIBLE** Type checking failures now raise + ``typeguard.TypeCheckError`` instead of ``TypeError`` +- Dropped Python 3.5 and 3.6 support +- Dropped the deprecated profiler hook (``TypeChecker``) +- Added a configuration system +- Added support for custom type checking functions +- Added support for PEP 604 union types (``X | Y``) on all Python versions +- Added support for generic built-in collection types (``list[int]`` et al) on all + Python versions +- Added support for checking arbitrary ``Mapping`` types +- Added support for the ``Self`` type +- Added support for ``typing.Never`` (and ``typing_extensions.Never``) +- Added support for ``Never`` and ``NoReturn`` in argument annotations +- Added support for ``LiteralString`` +- Added support for ``TypeGuard`` +- Added support for the subclassable ``Any`` on Python 3.11 and ``typing_extensions`` +- Added the possibility to have the import hook instrument all packages +- Added the ``suppress_type_checks()`` context manager function for temporarily + disabling type checks +- Much improved error messages showing where the type check failed +- Made it possible to apply ``@typechecked`` on top of ``@classmethod`` / + ``@staticmethod`` (PR by jacobpbrugh) +- Changed ``check_type()`` to return the passed value, so it can be used (to an extent) + in place of ``typing.cast()``, but with run-time type checking +- Replaced custom implementation of ``is_typeddict()`` with the implementation from + ``typing_extensions`` v4.1.0 +- Emit ``InstrumentationWarning`` instead of raising ``RuntimeError`` from the pytest + plugin if modules in the target package have already been imported +- Fixed ``TypeError`` when checking against ``TypedDict`` when the value has mixed types + among the extra keys (PR by biolds) +- Fixed incompatibility with ``typing_extensions`` v4.1+ on Python 3.10 (PR by David C.) +- Fixed checking of ``Tuple[()]`` on Python 3.11 and ``tuple[()]`` on Python 3.9+ +- Fixed integers 0 and 1 passing for ``Literal[False]`` and ``Literal[True]``, + respectively +- Fixed type checking of annotated variable positional and keyword arguments (``*args`` + and ``**kwargs``) +- Fixed checks against ``unittest.Mock`` and derivatives being done in the wrong place + +**2.13.3** (2021-12-10) + +- Fixed ``TypeError`` when using typeguard within ``exec()`` (where ``__module__`` is ``None``) + (PR by Andy Jones) +- Fixed ``TypedDict`` causing ``TypeError: TypedDict does not support instance and class checks`` + on Python 3.8 with standard library (not ``typing_extensions``) typed dicts + +**2.13.2** (2021-11-23) + +- Fixed ``typing_extensions`` being imported unconditionally on Python < 3.9 + (bug introduced in 2.13.1) + +**2.13.1** (2021-11-23) + +- Fixed ``@typechecked`` replacing abstract properties with regular properties +- Fixed any generic type subclassing ``Dict`` being mistakenly checked as ``TypedDict`` on + Python 3.10 + +**2.13.0** (2021-10-11) + +- Added support for returning ``NotImplemented`` from binary magic methods (``__eq__()`` et al) +- Added support for checking union types (e.g. ``Type[Union[X, Y]]``) +- Fixed error message when a check against a ``Literal`` fails in a union on Python 3.10 +- Fixed ``NewType`` not being checked on Python 3.10 +- Fixed unwarranted warning when ``@typechecked`` is applied to a class that contains unannotated + properties +- Fixed ``TypeError`` in the async generator wrapper due to changes in ``__aiter__()`` protocol +- Fixed broken ``TypeVar`` checks – variance is now (correctly) disregarded, and only bound types + and constraints are checked against (but type variable resolution is not done) + +**2.12.1** (2021-06-04) + +- Fixed ``AttributeError`` when ``__code__`` is missing from the checked callable (PR by epenet) + +**2.12.0** (2021-04-01) + +- Added ``@typeguard_ignore`` decorator to exclude specific functions and classes from + runtime type checking (PR by Claudio Jolowicz) + +**2.11.1** (2021-02-16) + +- Fixed compatibility with Python 3.10 + +**2.11.0** (2021-02-13) + +- Added support for type checking class properties (PR by Ethan Pronovost) +- Fixed static type checking of ``@typechecked`` decorators (PR by Kenny Stauffer) +- Fixed wrong error message when type check against a ``bytes`` declaration fails +- Allowed ``memoryview`` objects to pass as ``bytes`` (like MyPy does) +- Shortened tracebacks (PR by prescod) + +**2.10.0** (2020-10-17) + +- Added support for Python 3.9 (PR by Csergő Bálint) +- Added support for nested ``Literal`` +- Added support for ``TypedDict`` inheritance (with some caveats; see the user guide on that for + details) +- An appropriate ``TypeError`` is now raised when encountering an illegal ``Literal`` value +- Fixed checking ``NoReturn`` on Python < 3.8 when ``typing_extensions`` was not installed +- Fixed import hook matching unwanted modules (PR by Wouter Bolsterlee) +- Install the pytest plugin earlier in the test run to support more use cases + (PR by Wouter Bolsterlee) + +**2.9.1** (2020-06-07) + +- Fixed ``ImportError`` on Python < 3.8 when ``typing_extensions`` was not installed + +**2.9.0** (2020-06-06) + +- Upped the minimum Python version from 3.5.2 to 3.5.3 +- Added support for ``typing.NoReturn`` +- Added full support for ``typing_extensions`` (now equivalent to support of the ``typing`` module) +- Added the option of supplying ``check_type()`` with globals/locals for correct resolution of + forward references +- Fixed erroneous ``TypeError`` when trying to check against non-runtime ``typing.Protocol`` + (skips the check for now until a proper compatibility check has been implemented) +- Fixed forward references in ``TypedDict`` not being resolved +- Fixed checking against recursive types + +**2.8.0** (2020-06-02) + +- Added support for the ``Mock`` and ``MagicMock`` types (PR by prescod) +- Added support for ``typing_extensions.Literal`` (PR by Ryan Rowe) +- Fixed unintended wrapping of untyped generators (PR by prescod) +- Fixed checking against bound type variables with ``check_type()`` without a call memo +- Fixed error message when checking against a ``Union`` containing a ``Literal`` + +**2.7.1** (2019-12-27) + +- Fixed ``@typechecked`` returning ``None`` when called with ``always=True`` and Python runs in + optimized mode +- Fixed performance regression introduced in v2.7.0 (the ``getattr_static()`` call was causing a 3x + slowdown) + +**2.7.0** (2019-12-10) + +- Added support for ``typing.Protocol`` subclasses +- Added support for ``typing.AbstractSet`` +- Fixed the handling of ``total=False`` in ``TypedDict`` +- Fixed no error reported on unknown keys with ``TypedDict`` +- Removed support of default values in ``TypedDict``, as they are not supported in the spec + +**2.6.1** (2019-11-17) + +- Fixed import errors when using the import hook and trying to import a module that has both a + module docstring and ``__future__`` imports in it +- Fixed ``AttributeError`` when using ``@typechecked`` on a metaclass +- Fixed ``@typechecked`` compatibility with built-in function wrappers +- Fixed type checking generator wrappers not being recognized as generators +- Fixed resolution of forward references in certain cases (inner classes, function-local classes) +- Fixed ``AttributeError`` when a class has contains a variable that is an instance of a class + that has a ``__call__()`` method +- Fixed class methods and static methods being wrapped incorrectly when ``@typechecked`` is applied + to the class +- Fixed ``AttributeError`` when ``@typechecked`` is applied to a function that has been decorated + with a decorator that does not properly wrap the original (PR by Joel Beach) +- Fixed collections with mixed value (or key) types raising ``TypeError`` on Python 3.7+ when + matched against unparametrized annotations from the ``typing`` module +- Fixed inadvertent ``TypeError`` when checking against a type variable that has constraints or + a bound type expressed as a forward reference + +**2.6.0** (2019-11-06) + +- Added a :pep:`302` import hook for annotating functions and classes with ``@typechecked`` +- Added a pytest plugin that activates the import hook +- Added support for ``typing.TypedDict`` +- Deprecated ``TypeChecker`` (will be removed in v3.0) + +**2.5.1** (2019-09-26) + +- Fixed incompatibility between annotated ``Iterable``, ``Iterator``, ``AsyncIterable`` or + ``AsyncIterator`` return types and generator/async generator functions +- Fixed ``TypeError`` being wrapped inside another TypeError (PR by russok) + +**2.5.0** (2019-08-26) + +- Added yield type checking via ``TypeChecker`` for regular generators +- Added yield, send and return type checking via ``@typechecked`` for regular and async generators +- Silenced ``TypeChecker`` warnings about async generators +- Fixed bogus ``TypeError`` on ``Type[Any]`` +- Fixed bogus ``TypeChecker`` warnings when an exception is raised from a type checked function +- Accept a ``bytearray`` where ``bytes`` are expected, as per `python/typing#552`_ +- Added policies for dealing with unmatched forward references +- Added support for using ``@typechecked`` as a class decorator +- Added ``check_return_type()`` to accompany ``check_argument_types()`` +- Added Sphinx documentation + +.. _python/typing#552: https://github.com/python/typing/issues/552 + +**2.4.1** (2019-07-15) + +- Fixed broken packaging configuration + +**2.4.0** (2019-07-14) + +- Added :pep:`561` support +- Added support for empty tuples (``Tuple[()]``) +- Added support for ``typing.Literal`` +- Make getting the caller frame faster (PR by Nick Sweeting) + +**2.3.1** (2019-04-12) + +- Fixed thread safety issue with the type hints cache (PR by Kelsey Francis) + +**2.3.0** (2019-03-27) + +- Added support for ``typing.IO`` and derivatives +- Fixed return type checking for coroutine functions +- Dropped support for Python 3.4 + +**2.2.2** (2018-08-13) + +- Fixed false positive when checking a callable against the plain ``typing.Callable`` on Python 3.7 + +**2.2.1** (2018-08-12) + +- Argument type annotations are no longer unioned with the types of their default values, except in + the case of ``None`` as the default value (although PEP 484 still recommends against this) +- Fixed some generic types (``typing.Collection`` among others) producing false negatives on + Python 3.7 +- Shortened unnecessarily long tracebacks by raising a new ``TypeError`` based on the old one +- Allowed type checking against arbitrary types by removing the requirement to supply a call memo + to ``check_type()`` +- Fixed ``AttributeError`` when running with the pydev debugger extension installed +- Fixed getting type names on ``typing.*`` on Python 3.7 (fix by Dale Jung) + +**2.2.0** (2018-07-08) + +- Fixed compatibility with Python 3.7 +- Removed support for Python 3.3 +- Added support for ``typing.NewType`` (contributed by reinhrst) + +**2.1.4** (2018-01-07) + +- Removed support for backports.typing, as it has been removed from PyPI +- Fixed checking of the numeric tower (complex -> float -> int) according to PEP 484 + +**2.1.3** (2017-03-13) + +- Fixed type checks against generic classes + +**2.1.2** (2017-03-12) + +- Fixed leak of function objects (should've used a ``WeakValueDictionary`` instead of + ``WeakKeyDictionary``) +- Fixed obscure failure of TypeChecker when it's unable to find the function object +- Fixed parametrized ``Type`` not working with type variables +- Fixed type checks against variable positional and keyword arguments + +**2.1.1** (2016-12-20) + +- Fixed formatting of README.rst so it renders properly on PyPI + +**2.1.0** (2016-12-17) + +- Added support for ``typings.Type`` (available in Python 3.5.2+) +- Added a third, ``sys.setprofile()`` based type checking approach (``typeguard.TypeChecker``) +- Changed certain type error messages to display "function" instead of the function's qualified + name + +**2.0.2** (2016-12-17) + +- More Python 3.6 compatibility fixes (along with a broader test suite) + +**2.0.1** (2016-12-10) + +- Fixed additional Python 3.6 compatibility issues + +**2.0.0** (2016-12-10) + +- **BACKWARD INCOMPATIBLE** Dropped Python 3.2 support +- Fixed incompatibility with Python 3.6 +- Use ``inspect.signature()`` in place of ``inspect.getfullargspec`` +- Added support for ``typing.NamedTuple`` + +**1.2.3** (2016-09-13) + +- Fixed ``@typechecked`` skipping the check of return value type when the type annotation was + ``None`` + +**1.2.2** (2016-08-23) + +- Fixed checking of homogenous Tuple declarations (``Tuple[bool, ...]``) + +**1.2.1** (2016-06-29) + +- Use ``backports.typing`` when possible to get new features on older Pythons +- Fixed incompatibility with Python 3.5.2 + +**1.2.0** (2016-05-21) + +- Fixed argument counting when a class is checked against a Callable specification +- Fixed argument counting when a functools.partial object is checked against a Callable + specification +- Added checks against mandatory keyword-only arguments when checking against a Callable + specification + +**1.1.3** (2016-05-09) + +- Gracefully exit if ``check_type_arguments`` can't find a reference to the current function + +**1.1.2** (2016-05-08) + +- Fixed TypeError when checking a builtin function against a parametrized Callable + +**1.1.1** (2016-01-03) + +- Fixed improper argument counting with bound methods when typechecking callables + +**1.1.0** (2016-01-02) + +- Eliminated the need to pass a reference to the currently executing function to + ``check_argument_types()`` + +**1.0.2** (2016-01-02) + +- Fixed types of default argument values not being considered as valid for the argument + +**1.0.1** (2016-01-01) + +- Fixed type hints retrieval being done for the wrong callable in cases where the callable was + wrapped with one or more decorators + +**1.0.0** (2015-12-28) + +- Initial release diff --git a/pyproject.toml b/pyproject.toml index 95b48ac..7c89494 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,113 @@ [build-system] -requires = ["setuptools >= 36.2.7", "wheel", "setuptools_scm >= 1.7.0"] +requires = [ + "setuptools >= 64", + "setuptools_scm[toml] >= 6.4" +] +build-backend = "setuptools.build_meta" + +[project] +name = "typeguard" +description = "Run-time type checker for Python" +readme = "README.rst" +authors = [{name = "Alex Grönholm", email = "alex.gronholm@nextday.fi"}] +license = {text = "MIT"} +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +requires-python = ">= 3.9" +dependencies = [ + "importlib_metadata >= 3.6; python_version < '3.10'", + "typing_extensions >= 4.10.0", +] +dynamic = ["version"] + +[project.urls] +Documentation = "https://typeguard.readthedocs.io/en/latest/" +"Change log" = "https://typeguard.readthedocs.io/en/latest/versionhistory.html" +"Source code" = "https://github.com/agronholm/typeguard" +"Issue tracker" = "https://github.com/agronholm/typeguard/issues" + +[project.optional-dependencies] +test = [ + "coverage[toml] >= 7", + "pytest >= 7", + 'mypy >= 1.2.0; python_implementation != "PyPy"', +] +doc = [ + "packaging", + "Sphinx >= 7", + "sphinx-autodoc-typehints >= 1.2.0", + "sphinx-rtd-theme >= 1.3.0", +] + +[project.entry-points] +pytest11 = {typeguard = "typeguard._pytest_plugin"} + +[tool.setuptools.package-data] +typeguard = ["py.typed"] + +[tool.setuptools_scm] +version_scheme = "post-release" +local_scheme = "dirty-tag" + +[tool.pytest.ini_options] +addopts = "--tb=short" +testpaths = "tests" +xfail_strict = true +filterwarnings = ["error"] + +[tool.coverage.run] +source = ["typeguard"] + +[tool.coverage.report] +show_missing = true +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:" +] + +[tool.ruff] +src = ["src"] + +[tool.ruff.lint] +extend-select = [ + "B0", # flake8-bugbear + "I", # isort + "PGH", # pygrep-hooks + "UP", # pyupgrade + "W", # pycodestyle warnings +] +ignore = [ + "S307", + "B008", + "UP006", + "UP035", +] + +[tool.mypy] +python_version = "3.11" +strict = true +pretty = true + +[tool.tox] +env_list = ["py39", "py310", "py311", "py312", "py313"] +skip_missing_interpreters = true + +[tool.tox.env_run_base] +commands = [["coverage", "run", "-m", "pytest", { replace = "posargs", extend = true }]] +package = "editable" +extras = ["test"] + +[tool.tox.env.docs] +depends = [] +extras = ["doc"] +commands = [["sphinx-build", "-W", "-n", "docs", "build/sphinx"]] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index e502980..0000000 --- a/setup.cfg +++ /dev/null @@ -1,47 +0,0 @@ -[metadata] -name = typeguard -description = Run-time type checker for Python -long_description = file: README.rst -author = Alex Grönholm -author_email = alex.gronholm@nextday.fi -project_urls = - Source code = https://github.com/agronholm/typeguard - Issue tracker = https://github.com/agronholm/typeguard/issues -license = MIT -license_file = LICENSE -classifiers = - Development Status :: 5 - Production/Stable - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.4 - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - -[options] -py_modules = typeguard -python_requires = >= 3.4 -zip_safe = True -install_requires = - typing >= 3.5; python_version == "3.4" - -[options.extras_require] -testing = - pytest - pytest-cov - -[tool:pytest] -addopts = -rsx --tb=short --cov -testpaths = tests - -[coverage:run] -source = typeguard - -[coverage:report] -show_missing = true - -[flake8] -max-line-length = 99 -ignore = E251 diff --git a/setup.py b/setup.py deleted file mode 100644 index 3e51f8b..0000000 --- a/setup.py +++ /dev/null @@ -1,11 +0,0 @@ -from setuptools import setup - -setup( - use_scm_version={ - 'version_scheme': 'post-release', - 'local_scheme': 'dirty-tag' - }, - setup_requires=[ - 'setuptools_scm >= 1.7.0' - ] -) diff --git a/src/typeguard/__init__.py b/src/typeguard/__init__.py new file mode 100644 index 0000000..6781cad --- /dev/null +++ b/src/typeguard/__init__.py @@ -0,0 +1,48 @@ +import os +from typing import Any + +from ._checkers import TypeCheckerCallable as TypeCheckerCallable +from ._checkers import TypeCheckLookupCallback as TypeCheckLookupCallback +from ._checkers import check_type_internal as check_type_internal +from ._checkers import checker_lookup_functions as checker_lookup_functions +from ._checkers import load_plugins as load_plugins +from ._config import CollectionCheckStrategy as CollectionCheckStrategy +from ._config import ForwardRefPolicy as ForwardRefPolicy +from ._config import TypeCheckConfiguration as TypeCheckConfiguration +from ._decorators import typechecked as typechecked +from ._decorators import typeguard_ignore as typeguard_ignore +from ._exceptions import InstrumentationWarning as InstrumentationWarning +from ._exceptions import TypeCheckError as TypeCheckError +from ._exceptions import TypeCheckWarning as TypeCheckWarning +from ._exceptions import TypeHintWarning as TypeHintWarning +from ._functions import TypeCheckFailCallback as TypeCheckFailCallback +from ._functions import check_type as check_type +from ._functions import warn_on_error as warn_on_error +from ._importhook import ImportHookManager as ImportHookManager +from ._importhook import TypeguardFinder as TypeguardFinder +from ._importhook import install_import_hook as install_import_hook +from ._memo import TypeCheckMemo as TypeCheckMemo +from ._suppression import suppress_type_checks as suppress_type_checks +from ._utils import Unset as Unset + +# Re-export imports so they look like they live directly in this package +for value in list(locals().values()): + if getattr(value, "__module__", "").startswith(f"{__name__}."): + value.__module__ = __name__ + + +config: TypeCheckConfiguration + + +def __getattr__(name: str) -> Any: + if name == "config": + from ._config import global_config + + return global_config + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +# Automatically load checker lookup functions unless explicitly disabled +if "TYPEGUARD_DISABLE_PLUGIN_AUTOLOAD" not in os.environ: + load_plugins() diff --git a/src/typeguard/_checkers.py b/src/typeguard/_checkers.py new file mode 100644 index 0000000..5e34036 --- /dev/null +++ b/src/typeguard/_checkers.py @@ -0,0 +1,1075 @@ +from __future__ import annotations + +import collections.abc +import inspect +import sys +import types +import typing +import warnings +from collections.abc import Mapping, MutableMapping, Sequence +from enum import Enum +from inspect import Parameter, isclass, isfunction +from io import BufferedIOBase, IOBase, RawIOBase, TextIOBase +from itertools import zip_longest +from textwrap import indent +from typing import ( + IO, + AbstractSet, + Annotated, + Any, + BinaryIO, + Callable, + Dict, + ForwardRef, + List, + NewType, + Optional, + Set, + TextIO, + Tuple, + Type, + TypeVar, + Union, +) +from unittest.mock import Mock + +import typing_extensions + +# Must use this because typing.is_typeddict does not recognize +# TypedDict from typing_extensions, and as of version 4.12.0 +# typing_extensions.TypedDict is different from typing.TypedDict +# on all versions. +from typing_extensions import is_typeddict + +from ._config import ForwardRefPolicy +from ._exceptions import TypeCheckError, TypeHintWarning +from ._memo import TypeCheckMemo +from ._utils import evaluate_forwardref, get_stacklevel, get_type_name, qualified_name + +if sys.version_info >= (3, 11): + from typing import ( + NotRequired, + TypeAlias, + get_args, + get_origin, + ) + + SubclassableAny = Any +else: + from typing_extensions import Any as SubclassableAny + from typing_extensions import ( + NotRequired, + TypeAlias, + get_args, + get_origin, + ) + +if sys.version_info >= (3, 10): + from importlib.metadata import entry_points + from typing import ParamSpec +else: + from importlib_metadata import entry_points + from typing_extensions import ParamSpec + +TypeCheckerCallable: TypeAlias = Callable[ + [Any, Any, Tuple[Any, ...], TypeCheckMemo], Any +] +TypeCheckLookupCallback: TypeAlias = Callable[ + [Any, Tuple[Any, ...], Tuple[Any, ...]], Optional[TypeCheckerCallable] +] + +checker_lookup_functions: list[TypeCheckLookupCallback] = [] +generic_alias_types: tuple[type, ...] = ( + type(List), + type(List[Any]), + types.GenericAlias, +) + +# Sentinel +_missing = object() + +# Lifted from mypy.sharedparse +BINARY_MAGIC_METHODS = { + "__add__", + "__and__", + "__cmp__", + "__divmod__", + "__div__", + "__eq__", + "__floordiv__", + "__ge__", + "__gt__", + "__iadd__", + "__iand__", + "__idiv__", + "__ifloordiv__", + "__ilshift__", + "__imatmul__", + "__imod__", + "__imul__", + "__ior__", + "__ipow__", + "__irshift__", + "__isub__", + "__itruediv__", + "__ixor__", + "__le__", + "__lshift__", + "__lt__", + "__matmul__", + "__mod__", + "__mul__", + "__ne__", + "__or__", + "__pow__", + "__radd__", + "__rand__", + "__rdiv__", + "__rfloordiv__", + "__rlshift__", + "__rmatmul__", + "__rmod__", + "__rmul__", + "__ror__", + "__rpow__", + "__rrshift__", + "__rshift__", + "__rsub__", + "__rtruediv__", + "__rxor__", + "__sub__", + "__truediv__", + "__xor__", +} + + +def check_callable( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if not callable(value): + raise TypeCheckError("is not callable") + + if args: + try: + signature = inspect.signature(value) + except (TypeError, ValueError): + return + + argument_types = args[0] + if isinstance(argument_types, list) and not any( + type(item) is ParamSpec for item in argument_types + ): + # The callable must not have keyword-only arguments without defaults + unfulfilled_kwonlyargs = [ + param.name + for param in signature.parameters.values() + if param.kind == Parameter.KEYWORD_ONLY + and param.default == Parameter.empty + ] + if unfulfilled_kwonlyargs: + raise TypeCheckError( + f"has mandatory keyword-only arguments in its declaration: " + f'{", ".join(unfulfilled_kwonlyargs)}' + ) + + num_positional_args = num_mandatory_pos_args = 0 + has_varargs = False + for param in signature.parameters.values(): + if param.kind in ( + Parameter.POSITIONAL_ONLY, + Parameter.POSITIONAL_OR_KEYWORD, + ): + num_positional_args += 1 + if param.default is Parameter.empty: + num_mandatory_pos_args += 1 + elif param.kind == Parameter.VAR_POSITIONAL: + has_varargs = True + + if num_mandatory_pos_args > len(argument_types): + raise TypeCheckError( + f"has too many mandatory positional arguments in its declaration; " + f"expected {len(argument_types)} but {num_mandatory_pos_args} " + f"mandatory positional argument(s) declared" + ) + elif not has_varargs and num_positional_args < len(argument_types): + raise TypeCheckError( + f"has too few arguments in its declaration; expected " + f"{len(argument_types)} but {num_positional_args} argument(s) " + f"declared" + ) + + +def check_mapping( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if origin_type is Dict or origin_type is dict: + if not isinstance(value, dict): + raise TypeCheckError("is not a dict") + if origin_type is MutableMapping or origin_type is collections.abc.MutableMapping: + if not isinstance(value, collections.abc.MutableMapping): + raise TypeCheckError("is not a mutable mapping") + elif not isinstance(value, collections.abc.Mapping): + raise TypeCheckError("is not a mapping") + + if args: + key_type, value_type = args + if key_type is not Any or value_type is not Any: + samples = memo.config.collection_check_strategy.iterate_samples( + value.items() + ) + for k, v in samples: + try: + check_type_internal(k, key_type, memo) + except TypeCheckError as exc: + exc.append_path_element(f"key {k!r}") + raise + + try: + check_type_internal(v, value_type, memo) + except TypeCheckError as exc: + exc.append_path_element(f"value of key {k!r}") + raise + + +def check_typed_dict( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if not isinstance(value, dict): + raise TypeCheckError("is not a dict") + + declared_keys = frozenset(origin_type.__annotations__) + if hasattr(origin_type, "__required_keys__"): + required_keys = set(origin_type.__required_keys__) + else: # py3.8 and lower + required_keys = set(declared_keys) if origin_type.__total__ else set() + + existing_keys = set(value) + extra_keys = existing_keys - declared_keys + if extra_keys: + keys_formatted = ", ".join(f'"{key}"' for key in sorted(extra_keys, key=repr)) + raise TypeCheckError(f"has unexpected extra key(s): {keys_formatted}") + + # Detect NotRequired fields which are hidden by get_type_hints() + type_hints: dict[str, type] = {} + for key, annotation in origin_type.__annotations__.items(): + if isinstance(annotation, ForwardRef): + annotation = evaluate_forwardref(annotation, memo) + + if get_origin(annotation) is NotRequired: + required_keys.discard(key) + annotation = get_args(annotation)[0] + + type_hints[key] = annotation + + missing_keys = required_keys - existing_keys + if missing_keys: + keys_formatted = ", ".join(f'"{key}"' for key in sorted(missing_keys, key=repr)) + raise TypeCheckError(f"is missing required key(s): {keys_formatted}") + + for key, argtype in type_hints.items(): + argvalue = value.get(key, _missing) + if argvalue is not _missing: + try: + check_type_internal(argvalue, argtype, memo) + except TypeCheckError as exc: + exc.append_path_element(f"value of key {key!r}") + raise + + +def check_list( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if not isinstance(value, list): + raise TypeCheckError("is not a list") + + if args and args != (Any,): + samples = memo.config.collection_check_strategy.iterate_samples(value) + for i, v in enumerate(samples): + try: + check_type_internal(v, args[0], memo) + except TypeCheckError as exc: + exc.append_path_element(f"item {i}") + raise + + +def check_sequence( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if not isinstance(value, collections.abc.Sequence): + raise TypeCheckError("is not a sequence") + + if args and args != (Any,): + samples = memo.config.collection_check_strategy.iterate_samples(value) + for i, v in enumerate(samples): + try: + check_type_internal(v, args[0], memo) + except TypeCheckError as exc: + exc.append_path_element(f"item {i}") + raise + + +def check_set( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if origin_type is frozenset: + if not isinstance(value, frozenset): + raise TypeCheckError("is not a frozenset") + elif not isinstance(value, AbstractSet): + raise TypeCheckError("is not a set") + + if args and args != (Any,): + samples = memo.config.collection_check_strategy.iterate_samples(value) + for v in samples: + try: + check_type_internal(v, args[0], memo) + except TypeCheckError as exc: + exc.append_path_element(f"[{v}]") + raise + + +def check_tuple( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + # Specialized check for NamedTuples + if field_types := getattr(origin_type, "__annotations__", None): + if not isinstance(value, origin_type): + raise TypeCheckError( + f"is not a named tuple of type {qualified_name(origin_type)}" + ) + + for name, field_type in field_types.items(): + try: + check_type_internal(getattr(value, name), field_type, memo) + except TypeCheckError as exc: + exc.append_path_element(f"attribute {name!r}") + raise + + return + elif not isinstance(value, tuple): + raise TypeCheckError("is not a tuple") + + if args: + use_ellipsis = args[-1] is Ellipsis + tuple_params = args[: -1 if use_ellipsis else None] + else: + # Unparametrized Tuple or plain tuple + return + + if use_ellipsis: + element_type = tuple_params[0] + samples = memo.config.collection_check_strategy.iterate_samples(value) + for i, element in enumerate(samples): + try: + check_type_internal(element, element_type, memo) + except TypeCheckError as exc: + exc.append_path_element(f"item {i}") + raise + elif tuple_params == ((),): + if value != (): + raise TypeCheckError("is not an empty tuple") + else: + if len(value) != len(tuple_params): + raise TypeCheckError( + f"has wrong number of elements (expected {len(tuple_params)}, got " + f"{len(value)} instead)" + ) + + for i, (element, element_type) in enumerate(zip(value, tuple_params)): + try: + check_type_internal(element, element_type, memo) + except TypeCheckError as exc: + exc.append_path_element(f"item {i}") + raise + + +def check_union( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + errors: dict[str, TypeCheckError] = {} + try: + for type_ in args: + try: + check_type_internal(value, type_, memo) + return + except TypeCheckError as exc: + errors[get_type_name(type_)] = exc + + formatted_errors = indent( + "\n".join(f"{key}: {error}" for key, error in errors.items()), " " + ) + finally: + del errors # avoid creating ref cycle + + raise TypeCheckError(f"did not match any element in the union:\n{formatted_errors}") + + +def check_uniontype( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if not args: + return check_instance(value, types.UnionType, (), memo) + + errors: dict[str, TypeCheckError] = {} + try: + for type_ in args: + try: + check_type_internal(value, type_, memo) + return + except TypeCheckError as exc: + errors[get_type_name(type_)] = exc + + formatted_errors = indent( + "\n".join(f"{key}: {error}" for key, error in errors.items()), " " + ) + finally: + del errors # avoid creating ref cycle + + raise TypeCheckError(f"did not match any element in the union:\n{formatted_errors}") + + +def check_class( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if not isclass(value) and not isinstance(value, generic_alias_types): + raise TypeCheckError("is not a class") + + if not args: + return + + if isinstance(args[0], ForwardRef): + expected_class = evaluate_forwardref(args[0], memo) + else: + expected_class = args[0] + + if expected_class is Any: + return + elif expected_class is typing_extensions.Self: + check_self(value, get_origin(expected_class), get_args(expected_class), memo) + elif getattr(expected_class, "_is_protocol", False): + check_protocol(value, expected_class, (), memo) + elif isinstance(expected_class, TypeVar): + check_typevar(value, expected_class, (), memo, subclass_check=True) + elif get_origin(expected_class) is Union: + errors: dict[str, TypeCheckError] = {} + try: + for arg in get_args(expected_class): + if arg is Any: + return + + try: + check_class(value, type, (arg,), memo) + return + except TypeCheckError as exc: + errors[get_type_name(arg)] = exc + else: + formatted_errors = indent( + "\n".join(f"{key}: {error}" for key, error in errors.items()), " " + ) + raise TypeCheckError( + f"did not match any element in the union:\n{formatted_errors}" + ) + finally: + del errors # avoid creating ref cycle + elif not issubclass(value, expected_class): # type: ignore[arg-type] + raise TypeCheckError(f"is not a subclass of {qualified_name(expected_class)}") + + +def check_newtype( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + check_type_internal(value, origin_type.__supertype__, memo) + + +def check_instance( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if not isinstance(value, origin_type): + raise TypeCheckError(f"is not an instance of {qualified_name(origin_type)}") + + +def check_typevar( + value: Any, + origin_type: TypeVar, + args: tuple[Any, ...], + memo: TypeCheckMemo, + *, + subclass_check: bool = False, +) -> None: + if origin_type.__bound__ is not None: + annotation = ( + Type[origin_type.__bound__] if subclass_check else origin_type.__bound__ + ) + check_type_internal(value, annotation, memo) + elif origin_type.__constraints__: + for constraint in origin_type.__constraints__: + annotation = Type[constraint] if subclass_check else constraint + try: + check_type_internal(value, annotation, memo) + except TypeCheckError: + pass + else: + break + else: + formatted_constraints = ", ".join( + get_type_name(constraint) for constraint in origin_type.__constraints__ + ) + raise TypeCheckError( + f"does not match any of the constraints " f"({formatted_constraints})" + ) + + +def _is_literal_type(typ: object) -> bool: + return typ is typing.Literal or typ is typing_extensions.Literal + + +def check_literal( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + def get_literal_args(literal_args: tuple[Any, ...]) -> tuple[Any, ...]: + retval: list[Any] = [] + for arg in literal_args: + if _is_literal_type(get_origin(arg)): + retval.extend(get_literal_args(arg.__args__)) + elif arg is None or isinstance(arg, (int, str, bytes, bool, Enum)): + retval.append(arg) + else: + raise TypeError( + f"Illegal literal value: {arg}" + ) # TypeError here is deliberate + + return tuple(retval) + + final_args = tuple(get_literal_args(args)) + try: + index = final_args.index(value) + except ValueError: + pass + else: + if type(final_args[index]) is type(value): + return + + formatted_args = ", ".join(repr(arg) for arg in final_args) + raise TypeCheckError(f"is not any of ({formatted_args})") from None + + +def check_literal_string( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + check_type_internal(value, str, memo) + + +def check_typeguard( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + check_type_internal(value, bool, memo) + + +def check_none( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if value is not None: + raise TypeCheckError("is not None") + + +def check_number( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if origin_type is complex and not isinstance(value, (complex, float, int)): + raise TypeCheckError("is neither complex, float or int") + elif origin_type is float and not isinstance(value, (float, int)): + raise TypeCheckError("is neither float or int") + + +def check_io( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if origin_type is TextIO or (origin_type is IO and args == (str,)): + if not isinstance(value, TextIOBase): + raise TypeCheckError("is not a text based I/O object") + elif origin_type is BinaryIO or (origin_type is IO and args == (bytes,)): + if not isinstance(value, (RawIOBase, BufferedIOBase)): + raise TypeCheckError("is not a binary I/O object") + elif not isinstance(value, IOBase): + raise TypeCheckError("is not an I/O object") + + +def check_signature_compatible(subject: type, protocol: type, attrname: str) -> None: + subject_sig = inspect.signature(getattr(subject, attrname)) + protocol_sig = inspect.signature(getattr(protocol, attrname)) + protocol_type: typing.Literal["instance", "class", "static"] = "instance" + subject_type: typing.Literal["instance", "class", "static"] = "instance" + + # Check if the protocol-side method is a class method or static method + if attrname in protocol.__dict__: + descriptor = protocol.__dict__[attrname] + if isinstance(descriptor, staticmethod): + protocol_type = "static" + elif isinstance(descriptor, classmethod): + protocol_type = "class" + + # Check if the subject-side method is a class method or static method + if attrname in subject.__dict__: + descriptor = subject.__dict__[attrname] + if isinstance(descriptor, staticmethod): + subject_type = "static" + elif isinstance(descriptor, classmethod): + subject_type = "class" + + if protocol_type == "instance" and subject_type != "instance": + raise TypeCheckError( + f"should be an instance method but it's a {subject_type} method" + ) + elif protocol_type != "instance" and subject_type == "instance": + raise TypeCheckError( + f"should be a {protocol_type} method but it's an instance method" + ) + + expected_varargs = any( + param + for param in protocol_sig.parameters.values() + if param.kind is Parameter.VAR_POSITIONAL + ) + has_varargs = any( + param + for param in subject_sig.parameters.values() + if param.kind is Parameter.VAR_POSITIONAL + ) + if expected_varargs and not has_varargs: + raise TypeCheckError("should accept variable positional arguments but doesn't") + + protocol_has_varkwargs = any( + param + for param in protocol_sig.parameters.values() + if param.kind is Parameter.VAR_KEYWORD + ) + subject_has_varkwargs = any( + param + for param in subject_sig.parameters.values() + if param.kind is Parameter.VAR_KEYWORD + ) + if protocol_has_varkwargs and not subject_has_varkwargs: + raise TypeCheckError("should accept variable keyword arguments but doesn't") + + # Check that the callable has at least the expect amount of positional-only + # arguments (and no extra positional-only arguments without default values) + if not has_varargs: + protocol_args = [ + param + for param in protocol_sig.parameters.values() + if param.kind + in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) + ] + subject_args = [ + param + for param in subject_sig.parameters.values() + if param.kind + in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) + ] + + # Remove the "self" parameter from the protocol arguments to match + if protocol_type == "instance": + protocol_args.pop(0) + + # Remove the "self" parameter from the subject arguments to match + if subject_type == "instance": + subject_args.pop(0) + + for protocol_arg, subject_arg in zip_longest(protocol_args, subject_args): + if protocol_arg is None: + if subject_arg.default is Parameter.empty: + raise TypeCheckError("has too many mandatory positional arguments") + + break + + if subject_arg is None: + raise TypeCheckError("has too few positional arguments") + + if ( + protocol_arg.kind is Parameter.POSITIONAL_OR_KEYWORD + and subject_arg.kind is Parameter.POSITIONAL_ONLY + ): + raise TypeCheckError( + f"has an argument ({subject_arg.name}) that should not be " + f"positional-only" + ) + + if ( + protocol_arg.kind is Parameter.POSITIONAL_OR_KEYWORD + and protocol_arg.name != subject_arg.name + ): + raise TypeCheckError( + f"has a positional argument ({subject_arg.name}) that should be " + f"named {protocol_arg.name!r} at this position" + ) + + protocol_kwonlyargs = { + param.name: param + for param in protocol_sig.parameters.values() + if param.kind is Parameter.KEYWORD_ONLY + } + subject_kwonlyargs = { + param.name: param + for param in subject_sig.parameters.values() + if param.kind is Parameter.KEYWORD_ONLY + } + if not subject_has_varkwargs: + # Check that the signature has at least the required keyword-only arguments, and + # no extra mandatory keyword-only arguments + if missing_kwonlyargs := [ + param.name + for param in protocol_kwonlyargs.values() + if param.name not in subject_kwonlyargs + ]: + raise TypeCheckError( + "is missing keyword-only arguments: " + ", ".join(missing_kwonlyargs) + ) + + if not protocol_has_varkwargs: + if extra_kwonlyargs := [ + param.name + for param in subject_kwonlyargs.values() + if param.default is Parameter.empty + and param.name not in protocol_kwonlyargs + ]: + raise TypeCheckError( + "has mandatory keyword-only arguments not present in the protocol: " + + ", ".join(extra_kwonlyargs) + ) + + +def check_protocol( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + origin_annotations = typing.get_type_hints(origin_type) + for attrname in sorted(typing_extensions.get_protocol_members(origin_type)): + if (annotation := origin_annotations.get(attrname)) is not None: + try: + subject_member = getattr(value, attrname) + except AttributeError: + raise TypeCheckError( + f"is not compatible with the {origin_type.__qualname__} " + f"protocol because it has no attribute named {attrname!r}" + ) from None + + try: + check_type_internal(subject_member, annotation, memo) + except TypeCheckError as exc: + raise TypeCheckError( + f"is not compatible with the {origin_type.__qualname__} " + f"protocol because its {attrname!r} attribute {exc}" + ) from None + elif callable(getattr(origin_type, attrname)): + try: + subject_member = getattr(value, attrname) + except AttributeError: + raise TypeCheckError( + f"is not compatible with the {origin_type.__qualname__} " + f"protocol because it has no method named {attrname!r}" + ) from None + + if not callable(subject_member): + raise TypeCheckError( + f"is not compatible with the {origin_type.__qualname__} " + f"protocol because its {attrname!r} attribute is not a callable" + ) + + # TODO: implement assignability checks for parameter and return value + # annotations + subject = value if isclass(value) else value.__class__ + try: + check_signature_compatible(subject, origin_type, attrname) + except TypeCheckError as exc: + raise TypeCheckError( + f"is not compatible with the {origin_type.__qualname__} " + f"protocol because its {attrname!r} method {exc}" + ) from None + + +def check_byteslike( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if not isinstance(value, (bytearray, bytes, memoryview)): + raise TypeCheckError("is not bytes-like") + + +def check_self( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + if memo.self_type is None: + raise TypeCheckError("cannot be checked against Self outside of a method call") + + if isclass(value): + if not issubclass(value, memo.self_type): + raise TypeCheckError( + f"is not a subclass of the self type ({qualified_name(memo.self_type)})" + ) + elif not isinstance(value, memo.self_type): + raise TypeCheckError( + f"is not an instance of the self type ({qualified_name(memo.self_type)})" + ) + + +def check_paramspec( + value: Any, + origin_type: Any, + args: tuple[Any, ...], + memo: TypeCheckMemo, +) -> None: + pass # No-op for now + + +def check_type_internal( + value: Any, + annotation: Any, + memo: TypeCheckMemo, +) -> None: + """ + Check that the given object is compatible with the given type annotation. + + This function should only be used by type checker callables. Applications should use + :func:`~.check_type` instead. + + :param value: the value to check + :param annotation: the type annotation to check against + :param memo: a memo object containing configuration and information necessary for + looking up forward references + """ + + if isinstance(annotation, ForwardRef): + try: + annotation = evaluate_forwardref(annotation, memo) + except NameError: + if memo.config.forward_ref_policy is ForwardRefPolicy.ERROR: + raise + elif memo.config.forward_ref_policy is ForwardRefPolicy.WARN: + warnings.warn( + f"Cannot resolve forward reference {annotation.__forward_arg__!r}", + TypeHintWarning, + stacklevel=get_stacklevel(), + ) + + return + + if annotation is Any or annotation is SubclassableAny or isinstance(value, Mock): + return + + # Skip type checks if value is an instance of a class that inherits from Any + if not isclass(value) and SubclassableAny in type(value).__bases__: + return + + extras: tuple[Any, ...] + origin_type = get_origin(annotation) + if origin_type is Annotated: + annotation, *extras_ = get_args(annotation) + extras = tuple(extras_) + origin_type = get_origin(annotation) + else: + extras = () + + if origin_type is not None: + args = get_args(annotation) + + # Compatibility hack to distinguish between unparametrized and empty tuple + # (tuple[()]), necessary due to https://github.com/python/cpython/issues/91137 + if origin_type in (tuple, Tuple) and annotation is not Tuple and not args: + args = ((),) + else: + origin_type = annotation + args = () + + for lookup_func in checker_lookup_functions: + checker = lookup_func(origin_type, args, extras) + if checker: + checker(value, origin_type, args, memo) + return + + if isclass(origin_type): + if not isinstance(value, origin_type): + raise TypeCheckError(f"is not an instance of {qualified_name(origin_type)}") + elif type(origin_type) is str: # noqa: E721 + warnings.warn( + f"Skipping type check against {origin_type!r}; this looks like a " + f"string-form forward reference imported from another module", + TypeHintWarning, + stacklevel=get_stacklevel(), + ) + + +# Equality checks are applied to these +origin_type_checkers = { + bytes: check_byteslike, + AbstractSet: check_set, + BinaryIO: check_io, + Callable: check_callable, + collections.abc.Callable: check_callable, + complex: check_number, + dict: check_mapping, + Dict: check_mapping, + float: check_number, + frozenset: check_set, + IO: check_io, + list: check_list, + List: check_list, + typing.Literal: check_literal, + Mapping: check_mapping, + MutableMapping: check_mapping, + None: check_none, + collections.abc.Mapping: check_mapping, + collections.abc.MutableMapping: check_mapping, + Sequence: check_sequence, + collections.abc.Sequence: check_sequence, + collections.abc.Set: check_set, + set: check_set, + Set: check_set, + TextIO: check_io, + tuple: check_tuple, + Tuple: check_tuple, + type: check_class, + Type: check_class, + Union: check_union, + # On some versions of Python, these may simply be re-exports from "typing", + # but exactly which Python versions is subject to change. + # It's best to err on the safe side and just always specify these. + typing_extensions.Literal: check_literal, + typing_extensions.LiteralString: check_literal_string, + typing_extensions.Self: check_self, + typing_extensions.TypeGuard: check_typeguard, +} +if sys.version_info >= (3, 10): + origin_type_checkers[types.UnionType] = check_uniontype + origin_type_checkers[typing.TypeGuard] = check_typeguard +if sys.version_info >= (3, 11): + origin_type_checkers.update( + {typing.LiteralString: check_literal_string, typing.Self: check_self} + ) + + +def builtin_checker_lookup( + origin_type: Any, args: tuple[Any, ...], extras: tuple[Any, ...] +) -> TypeCheckerCallable | None: + checker = origin_type_checkers.get(origin_type) + if checker is not None: + return checker + elif is_typeddict(origin_type): + return check_typed_dict + elif isclass(origin_type) and issubclass( + origin_type, + Tuple, # type: ignore[arg-type] + ): + # NamedTuple + return check_tuple + elif getattr(origin_type, "_is_protocol", False): + return check_protocol + elif isinstance(origin_type, ParamSpec): + return check_paramspec + elif isinstance(origin_type, TypeVar): + return check_typevar + elif origin_type.__class__ is NewType: + # typing.NewType on Python 3.10+ + return check_newtype + elif ( + isfunction(origin_type) + and getattr(origin_type, "__module__", None) == "typing" + and getattr(origin_type, "__qualname__", "").startswith("NewType.") + and hasattr(origin_type, "__supertype__") + ): + # typing.NewType on Python 3.9 and below + return check_newtype + + return None + + +checker_lookup_functions.append(builtin_checker_lookup) + + +def load_plugins() -> None: + """ + Load all type checker lookup functions from entry points. + + All entry points from the ``typeguard.checker_lookup`` group are loaded, and the + returned lookup functions are added to :data:`typeguard.checker_lookup_functions`. + + .. note:: This function is called implicitly on import, unless the + ``TYPEGUARD_DISABLE_PLUGIN_AUTOLOAD`` environment variable is present. + """ + + for ep in entry_points(group="typeguard.checker_lookup"): + try: + plugin = ep.load() + except Exception as exc: + warnings.warn( + f"Failed to load plugin {ep.name!r}: " f"{qualified_name(exc)}: {exc}", + stacklevel=2, + ) + continue + + if not callable(plugin): + warnings.warn( + f"Plugin {ep} returned a non-callable object: {plugin!r}", stacklevel=2 + ) + continue + + checker_lookup_functions.insert(0, plugin) diff --git a/src/typeguard/_config.py b/src/typeguard/_config.py new file mode 100644 index 0000000..36efad5 --- /dev/null +++ b/src/typeguard/_config.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from enum import Enum, auto +from typing import TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from ._functions import TypeCheckFailCallback + +T = TypeVar("T") + + +class ForwardRefPolicy(Enum): + """ + Defines how unresolved forward references are handled. + + Members: + + * ``ERROR``: propagate the :exc:`NameError` when the forward reference lookup fails + * ``WARN``: emit a :class:`~.TypeHintWarning` if the forward reference lookup fails + * ``IGNORE``: silently skip checks for unresolveable forward references + """ + + ERROR = auto() + WARN = auto() + IGNORE = auto() + + +class CollectionCheckStrategy(Enum): + """ + Specifies how thoroughly the contents of collections are type checked. + + This has an effect on the following built-in checkers: + + * ``AbstractSet`` + * ``Dict`` + * ``List`` + * ``Mapping`` + * ``Set`` + * ``Tuple[, ...]`` (arbitrarily sized tuples) + + Members: + + * ``FIRST_ITEM``: check only the first item + * ``ALL_ITEMS``: check all items + """ + + FIRST_ITEM = auto() + ALL_ITEMS = auto() + + def iterate_samples(self, collection: Iterable[T]) -> Iterable[T]: + if self is CollectionCheckStrategy.FIRST_ITEM: + try: + return [next(iter(collection))] + except StopIteration: + return () + else: + return collection + + +@dataclass +class TypeCheckConfiguration: + """ + You can change Typeguard's behavior with these settings. + + .. attribute:: typecheck_fail_callback + :type: Callable[[TypeCheckError, TypeCheckMemo], Any] + + Callable that is called when type checking fails. + + Default: ``None`` (the :exc:`~.TypeCheckError` is raised directly) + + .. attribute:: forward_ref_policy + :type: ForwardRefPolicy + + Specifies what to do when a forward reference fails to resolve. + + Default: ``WARN`` + + .. attribute:: collection_check_strategy + :type: CollectionCheckStrategy + + Specifies how thoroughly the contents of collections (list, dict, etc.) are + type checked. + + Default: ``FIRST_ITEM`` + + .. attribute:: debug_instrumentation + :type: bool + + If set to ``True``, the code of modules or functions instrumented by typeguard + is printed to ``sys.stderr`` after the instrumentation is done + + Requires Python 3.9 or newer. + + Default: ``False`` + """ + + forward_ref_policy: ForwardRefPolicy = ForwardRefPolicy.WARN + typecheck_fail_callback: TypeCheckFailCallback | None = None + collection_check_strategy: CollectionCheckStrategy = ( + CollectionCheckStrategy.FIRST_ITEM + ) + debug_instrumentation: bool = False + + +global_config = TypeCheckConfiguration() diff --git a/src/typeguard/_decorators.py b/src/typeguard/_decorators.py new file mode 100644 index 0000000..a6c20cb --- /dev/null +++ b/src/typeguard/_decorators.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import ast +import inspect +import sys +from collections.abc import Sequence +from functools import partial +from inspect import isclass, isfunction +from types import CodeType, FrameType, FunctionType +from typing import TYPE_CHECKING, Any, Callable, ForwardRef, TypeVar, cast, overload +from warnings import warn + +from ._config import CollectionCheckStrategy, ForwardRefPolicy, global_config +from ._exceptions import InstrumentationWarning +from ._functions import TypeCheckFailCallback +from ._transformer import TypeguardTransformer +from ._utils import Unset, function_name, get_stacklevel, is_method_of, unset + +T_CallableOrType = TypeVar("T_CallableOrType", bound=Callable[..., Any]) + +if TYPE_CHECKING: + from typeshed.stdlib.types import _Cell + + def typeguard_ignore(arg: T_CallableOrType) -> T_CallableOrType: + """This decorator is a noop during static type-checking.""" + return arg + +else: + from typing import no_type_check as typeguard_ignore # noqa: F401 + + +def make_cell(value: object) -> _Cell: + return (lambda: value).__closure__[0] # type: ignore[index] + + +def find_target_function( + new_code: CodeType, target_path: Sequence[str], firstlineno: int +) -> CodeType | None: + target_name = target_path[0] + for const in new_code.co_consts: + if isinstance(const, CodeType): + if const.co_name == target_name: + if const.co_firstlineno == firstlineno: + return const + elif len(target_path) > 1: + target_code = find_target_function( + const, target_path[1:], firstlineno + ) + if target_code: + return target_code + + return None + + +def instrument(f: T_CallableOrType) -> FunctionType | str: + if not getattr(f, "__code__", None): + return "no code associated" + elif not getattr(f, "__module__", None): + return "__module__ attribute is not set" + elif f.__code__.co_filename == "": + return "cannot instrument functions defined in a REPL" + elif hasattr(f, "__wrapped__"): + return ( + "@typechecked only supports instrumenting functions wrapped with " + "@classmethod, @staticmethod or @property" + ) + + target_path = [item for item in f.__qualname__.split(".") if item != ""] + module_source = inspect.getsource(sys.modules[f.__module__]) + module_ast = ast.parse(module_source) + instrumentor = TypeguardTransformer(target_path, f.__code__.co_firstlineno) + instrumentor.visit(module_ast) + + if not instrumentor.target_node or instrumentor.target_lineno is None: + return "instrumentor did not find the target function" + + module_code = compile(module_ast, f.__code__.co_filename, "exec", dont_inherit=True) + new_code = find_target_function( + module_code, target_path, instrumentor.target_lineno + ) + if not new_code: + return "cannot find the target function in the AST" + + if global_config.debug_instrumentation and sys.version_info >= (3, 9): + # Find the matching AST node, then unparse it to source and print to stdout + print( + f"Source code of {f.__qualname__}() after instrumentation:" + "\n----------------------------------------------", + file=sys.stderr, + ) + print(ast.unparse(instrumentor.target_node), file=sys.stderr) + print( + "----------------------------------------------", + file=sys.stderr, + ) + + closure = f.__closure__ + if new_code.co_freevars != f.__code__.co_freevars: + # Create a new closure and find values for the new free variables + frame = cast(FrameType, inspect.currentframe()) + frame = cast(FrameType, frame.f_back) + frame_locals = cast(FrameType, frame.f_back).f_locals + cells: list[_Cell] = [] + for key in new_code.co_freevars: + if key in instrumentor.names_used_in_annotations: + # Find the value and make a new cell from it + value = frame_locals.get(key) or ForwardRef(key) + cells.append(make_cell(value)) + else: + # Reuse the cell from the existing closure + assert f.__closure__ + cells.append(f.__closure__[f.__code__.co_freevars.index(key)]) + + closure = tuple(cells) + + new_function = FunctionType(new_code, f.__globals__, f.__name__, closure=closure) + new_function.__module__ = f.__module__ + new_function.__name__ = f.__name__ + new_function.__qualname__ = f.__qualname__ + new_function.__annotations__ = f.__annotations__ + new_function.__doc__ = f.__doc__ + new_function.__defaults__ = f.__defaults__ + new_function.__kwdefaults__ = f.__kwdefaults__ + return new_function + + +@overload +def typechecked( + *, + forward_ref_policy: ForwardRefPolicy | Unset = unset, + typecheck_fail_callback: TypeCheckFailCallback | Unset = unset, + collection_check_strategy: CollectionCheckStrategy | Unset = unset, + debug_instrumentation: bool | Unset = unset, +) -> Callable[[T_CallableOrType], T_CallableOrType]: ... + + +@overload +def typechecked(target: T_CallableOrType) -> T_CallableOrType: ... + + +def typechecked( + target: T_CallableOrType | None = None, + *, + forward_ref_policy: ForwardRefPolicy | Unset = unset, + typecheck_fail_callback: TypeCheckFailCallback | Unset = unset, + collection_check_strategy: CollectionCheckStrategy | Unset = unset, + debug_instrumentation: bool | Unset = unset, +) -> Any: + """ + Instrument the target function to perform run-time type checking. + + This decorator recompiles the target function, injecting code to type check + arguments, return values, yield values (excluding ``yield from``) and assignments to + annotated local variables. + + This can also be used as a class decorator. This will instrument all type annotated + methods, including :func:`@classmethod `, + :func:`@staticmethod `, and :class:`@property ` decorated + methods in the class. + + .. note:: When Python is run in optimized mode (``-O`` or ``-OO``, this decorator + is a no-op). This is a feature meant for selectively introducing type checking + into a code base where the checks aren't meant to be run in production. + + :param target: the function or class to enable type checking for + :param forward_ref_policy: override for + :attr:`.TypeCheckConfiguration.forward_ref_policy` + :param typecheck_fail_callback: override for + :attr:`.TypeCheckConfiguration.typecheck_fail_callback` + :param collection_check_strategy: override for + :attr:`.TypeCheckConfiguration.collection_check_strategy` + :param debug_instrumentation: override for + :attr:`.TypeCheckConfiguration.debug_instrumentation` + + """ + if target is None: + return partial( + typechecked, + forward_ref_policy=forward_ref_policy, + typecheck_fail_callback=typecheck_fail_callback, + collection_check_strategy=collection_check_strategy, + debug_instrumentation=debug_instrumentation, + ) + + if not __debug__: + return target + + if isclass(target): + for key, attr in target.__dict__.items(): + if is_method_of(attr, target): + retval = instrument(attr) + if isfunction(retval): + setattr(target, key, retval) + elif isinstance(attr, (classmethod, staticmethod)): + if is_method_of(attr.__func__, target): + retval = instrument(attr.__func__) + if isfunction(retval): + wrapper = attr.__class__(retval) + setattr(target, key, wrapper) + elif isinstance(attr, property): + kwargs: dict[str, Any] = dict(doc=attr.__doc__) + for name in ("fset", "fget", "fdel"): + property_func = kwargs[name] = getattr(attr, name) + if is_method_of(property_func, target): + retval = instrument(property_func) + if isfunction(retval): + kwargs[name] = retval + + setattr(target, key, attr.__class__(**kwargs)) + + return target + + # Find either the first Python wrapper or the actual function + wrapper_class: ( + type[classmethod[Any, Any, Any]] | type[staticmethod[Any, Any]] | None + ) = None + if isinstance(target, (classmethod, staticmethod)): + wrapper_class = target.__class__ + target = target.__func__ # type: ignore[assignment] + + retval = instrument(target) + if isinstance(retval, str): + warn( + f"{retval} -- not typechecking {function_name(target)}", + InstrumentationWarning, + stacklevel=get_stacklevel(), + ) + return target + + if wrapper_class is None: + return retval + else: + return wrapper_class(retval) diff --git a/src/typeguard/_exceptions.py b/src/typeguard/_exceptions.py new file mode 100644 index 0000000..625437a --- /dev/null +++ b/src/typeguard/_exceptions.py @@ -0,0 +1,42 @@ +from collections import deque +from typing import Deque + + +class TypeHintWarning(UserWarning): + """ + A warning that is emitted when a type hint in string form could not be resolved to + an actual type. + """ + + +class TypeCheckWarning(UserWarning): + """Emitted by typeguard's type checkers when a type mismatch is detected.""" + + def __init__(self, message: str): + super().__init__(message) + + +class InstrumentationWarning(UserWarning): + """Emitted when there's a problem with instrumenting a function for type checks.""" + + def __init__(self, message: str): + super().__init__(message) + + +class TypeCheckError(Exception): + """ + Raised by typeguard's type checkers when a type mismatch is detected. + """ + + def __init__(self, message: str): + super().__init__(message) + self._path: Deque[str] = deque() + + def append_path_element(self, element: str) -> None: + self._path.append(element) + + def __str__(self) -> str: + if self._path: + return " of ".join(self._path) + " " + str(self.args[0]) + else: + return str(self.args[0]) diff --git a/src/typeguard/_functions.py b/src/typeguard/_functions.py new file mode 100644 index 0000000..ca21c14 --- /dev/null +++ b/src/typeguard/_functions.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import sys +import warnings +from collections.abc import Sequence +from typing import Any, Callable, NoReturn, TypeVar, Union, overload + +from . import _suppression +from ._checkers import BINARY_MAGIC_METHODS, check_type_internal +from ._config import ( + CollectionCheckStrategy, + ForwardRefPolicy, + TypeCheckConfiguration, +) +from ._exceptions import TypeCheckError, TypeCheckWarning +from ._memo import TypeCheckMemo +from ._utils import get_stacklevel, qualified_name + +if sys.version_info >= (3, 11): + from typing import Literal, Never, TypeAlias +else: + from typing_extensions import Literal, Never, TypeAlias + +T = TypeVar("T") +TypeCheckFailCallback: TypeAlias = Callable[[TypeCheckError, TypeCheckMemo], Any] + + +@overload +def check_type( + value: object, + expected_type: type[T], + *, + forward_ref_policy: ForwardRefPolicy = ..., + typecheck_fail_callback: TypeCheckFailCallback | None = ..., + collection_check_strategy: CollectionCheckStrategy = ..., +) -> T: ... + + +@overload +def check_type( + value: object, + expected_type: Any, + *, + forward_ref_policy: ForwardRefPolicy = ..., + typecheck_fail_callback: TypeCheckFailCallback | None = ..., + collection_check_strategy: CollectionCheckStrategy = ..., +) -> Any: ... + + +def check_type( + value: object, + expected_type: Any, + *, + forward_ref_policy: ForwardRefPolicy = TypeCheckConfiguration().forward_ref_policy, + typecheck_fail_callback: TypeCheckFailCallback | None = ( + TypeCheckConfiguration().typecheck_fail_callback + ), + collection_check_strategy: CollectionCheckStrategy = ( + TypeCheckConfiguration().collection_check_strategy + ), +) -> Any: + """ + Ensure that ``value`` matches ``expected_type``. + + The types from the :mod:`typing` module do not support :func:`isinstance` or + :func:`issubclass` so a number of type specific checks are required. This function + knows which checker to call for which type. + + This function wraps :func:`~.check_type_internal` in the following ways: + + * Respects type checking suppression (:func:`~.suppress_type_checks`) + * Forms a :class:`~.TypeCheckMemo` from the current stack frame + * Calls the configured type check fail callback if the check fails + + Note that this function is independent of the globally shared configuration in + :data:`typeguard.config`. This means that usage within libraries is safe from being + affected configuration changes made by other libraries or by the integrating + application. Instead, configuration options have the same default values as their + corresponding fields in :class:`TypeCheckConfiguration`. + + :param value: value to be checked against ``expected_type`` + :param expected_type: a class or generic type instance, or a tuple of such things + :param forward_ref_policy: see :attr:`TypeCheckConfiguration.forward_ref_policy` + :param typecheck_fail_callback: + see :attr`TypeCheckConfiguration.typecheck_fail_callback` + :param collection_check_strategy: + see :attr:`TypeCheckConfiguration.collection_check_strategy` + :return: ``value``, unmodified + :raises TypeCheckError: if there is a type mismatch + + """ + if type(expected_type) is tuple: + expected_type = Union[expected_type] + + config = TypeCheckConfiguration( + forward_ref_policy=forward_ref_policy, + typecheck_fail_callback=typecheck_fail_callback, + collection_check_strategy=collection_check_strategy, + ) + + if _suppression.type_checks_suppressed or expected_type is Any: + return value + + frame = sys._getframe(1) + memo = TypeCheckMemo(frame.f_globals, frame.f_locals, config=config) + try: + check_type_internal(value, expected_type, memo) + except TypeCheckError as exc: + exc.append_path_element(qualified_name(value, add_class_prefix=True)) + if config.typecheck_fail_callback: + config.typecheck_fail_callback(exc, memo) + else: + raise + + return value + + +def check_argument_types( + func_name: str, + arguments: dict[str, tuple[Any, Any]], + memo: TypeCheckMemo, +) -> Literal[True]: + if _suppression.type_checks_suppressed: + return True + + for argname, (value, annotation) in arguments.items(): + if annotation is NoReturn or annotation is Never: + exc = TypeCheckError( + f"{func_name}() was declared never to be called but it was" + ) + if memo.config.typecheck_fail_callback: + memo.config.typecheck_fail_callback(exc, memo) + else: + raise exc + + try: + check_type_internal(value, annotation, memo) + except TypeCheckError as exc: + qualname = qualified_name(value, add_class_prefix=True) + exc.append_path_element(f'argument "{argname}" ({qualname})') + if memo.config.typecheck_fail_callback: + memo.config.typecheck_fail_callback(exc, memo) + else: + raise + + return True + + +def check_return_type( + func_name: str, + retval: T, + annotation: Any, + memo: TypeCheckMemo, +) -> T: + if _suppression.type_checks_suppressed: + return retval + + if annotation is NoReturn or annotation is Never: + exc = TypeCheckError(f"{func_name}() was declared never to return but it did") + if memo.config.typecheck_fail_callback: + memo.config.typecheck_fail_callback(exc, memo) + else: + raise exc + + try: + check_type_internal(retval, annotation, memo) + except TypeCheckError as exc: + # Allow NotImplemented if this is a binary magic method (__eq__() et al) + if retval is NotImplemented and annotation is bool: + # This does (and cannot) not check if it's actually a method + func_name = func_name.rsplit(".", 1)[-1] + if func_name in BINARY_MAGIC_METHODS: + return retval + + qualname = qualified_name(retval, add_class_prefix=True) + exc.append_path_element(f"the return value ({qualname})") + if memo.config.typecheck_fail_callback: + memo.config.typecheck_fail_callback(exc, memo) + else: + raise + + return retval + + +def check_send_type( + func_name: str, + sendval: T, + annotation: Any, + memo: TypeCheckMemo, +) -> T: + if _suppression.type_checks_suppressed: + return sendval + + if annotation is NoReturn or annotation is Never: + exc = TypeCheckError( + f"{func_name}() was declared never to be sent a value to but it was" + ) + if memo.config.typecheck_fail_callback: + memo.config.typecheck_fail_callback(exc, memo) + else: + raise exc + + try: + check_type_internal(sendval, annotation, memo) + except TypeCheckError as exc: + qualname = qualified_name(sendval, add_class_prefix=True) + exc.append_path_element(f"the value sent to generator ({qualname})") + if memo.config.typecheck_fail_callback: + memo.config.typecheck_fail_callback(exc, memo) + else: + raise + + return sendval + + +def check_yield_type( + func_name: str, + yieldval: T, + annotation: Any, + memo: TypeCheckMemo, +) -> T: + if _suppression.type_checks_suppressed: + return yieldval + + if annotation is NoReturn or annotation is Never: + exc = TypeCheckError(f"{func_name}() was declared never to yield but it did") + if memo.config.typecheck_fail_callback: + memo.config.typecheck_fail_callback(exc, memo) + else: + raise exc + + try: + check_type_internal(yieldval, annotation, memo) + except TypeCheckError as exc: + qualname = qualified_name(yieldval, add_class_prefix=True) + exc.append_path_element(f"the yielded value ({qualname})") + if memo.config.typecheck_fail_callback: + memo.config.typecheck_fail_callback(exc, memo) + else: + raise + + return yieldval + + +def check_variable_assignment( + value: Any, targets: Sequence[list[tuple[str, Any]]], memo: TypeCheckMemo +) -> Any: + if _suppression.type_checks_suppressed: + return value + + value_to_return = value + for target in targets: + star_variable_index = next( + (i for i, (varname, _) in enumerate(target) if varname.startswith("*")), + None, + ) + if star_variable_index is not None: + value_to_return = list(value) + remaining_vars = len(target) - 1 - star_variable_index + end_index = len(value_to_return) - remaining_vars + values_to_check = ( + value_to_return[:star_variable_index] + + [value_to_return[star_variable_index:end_index]] + + value_to_return[end_index:] + ) + elif len(target) > 1: + values_to_check = value_to_return = [] + iterator = iter(value) + for _ in target: + try: + values_to_check.append(next(iterator)) + except StopIteration: + raise ValueError( + f"not enough values to unpack (expected {len(target)}, got " + f"{len(values_to_check)})" + ) from None + + else: + values_to_check = [value] + + for val, (varname, annotation) in zip(values_to_check, target): + try: + check_type_internal(val, annotation, memo) + except TypeCheckError as exc: + qualname = qualified_name(val, add_class_prefix=True) + exc.append_path_element(f"value assigned to {varname} ({qualname})") + if memo.config.typecheck_fail_callback: + memo.config.typecheck_fail_callback(exc, memo) + else: + raise + + return value_to_return + + +def warn_on_error(exc: TypeCheckError, memo: TypeCheckMemo) -> None: + """ + Emit a warning on a type mismatch. + + This is intended to be used as an error handler in + :attr:`TypeCheckConfiguration.typecheck_fail_callback`. + + """ + warnings.warn(TypeCheckWarning(str(exc)), stacklevel=get_stacklevel()) diff --git a/src/typeguard/_importhook.py b/src/typeguard/_importhook.py new file mode 100644 index 0000000..0d1c627 --- /dev/null +++ b/src/typeguard/_importhook.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import ast +import sys +import types +from collections.abc import Callable, Iterable, Sequence +from importlib.abc import MetaPathFinder +from importlib.machinery import ModuleSpec, SourceFileLoader +from importlib.util import cache_from_source, decode_source +from inspect import isclass +from os import PathLike +from types import CodeType, ModuleType, TracebackType +from typing import TypeVar +from unittest.mock import patch + +from ._config import global_config +from ._transformer import TypeguardTransformer + +if sys.version_info >= (3, 12): + from collections.abc import Buffer +else: + from typing_extensions import Buffer + +if sys.version_info >= (3, 11): + from typing import ParamSpec +else: + from typing_extensions import ParamSpec + +if sys.version_info >= (3, 10): + from importlib.metadata import PackageNotFoundError, version +else: + from importlib_metadata import PackageNotFoundError, version + +try: + OPTIMIZATION = "typeguard" + "".join(version("typeguard").split(".")[:3]) +except PackageNotFoundError: + OPTIMIZATION = "typeguard" + +P = ParamSpec("P") +T = TypeVar("T") + + +# The name of this function is magical +def _call_with_frames_removed( + f: Callable[P, T], *args: P.args, **kwargs: P.kwargs +) -> T: + return f(*args, **kwargs) + + +def optimized_cache_from_source(path: str, debug_override: bool | None = None) -> str: + return cache_from_source(path, debug_override, optimization=OPTIMIZATION) + + +class TypeguardLoader(SourceFileLoader): + @staticmethod + def source_to_code( + data: Buffer | str | ast.Module | ast.Expression | ast.Interactive, + path: Buffer | str | PathLike[str] = "", + ) -> CodeType: + if isinstance(data, (ast.Module, ast.Expression, ast.Interactive)): + tree = data + else: + if isinstance(data, str): + source = data + else: + source = decode_source(data) + + tree = _call_with_frames_removed( + ast.parse, + source, + path, + "exec", + ) + + tree = TypeguardTransformer().visit(tree) + ast.fix_missing_locations(tree) + + if global_config.debug_instrumentation and sys.version_info >= (3, 9): + print( + f"Source code of {path!r} after instrumentation:\n" + "----------------------------------------------", + file=sys.stderr, + ) + print(ast.unparse(tree), file=sys.stderr) + print("----------------------------------------------", file=sys.stderr) + + return _call_with_frames_removed( + compile, tree, path, "exec", 0, dont_inherit=True + ) + + def exec_module(self, module: ModuleType) -> None: + # Use a custom optimization marker – the import lock should make this monkey + # patch safe + with patch( + "importlib._bootstrap_external.cache_from_source", + optimized_cache_from_source, + ): + super().exec_module(module) + + +class TypeguardFinder(MetaPathFinder): + """ + Wraps another path finder and instruments the module with + :func:`@typechecked ` if :meth:`should_instrument` returns + ``True``. + + Should not be used directly, but rather via :func:`~.install_import_hook`. + + .. versionadded:: 2.6 + """ + + def __init__(self, packages: list[str] | None, original_pathfinder: MetaPathFinder): + self.packages = packages + self._original_pathfinder = original_pathfinder + + def find_spec( + self, + fullname: str, + path: Sequence[str] | None, + target: types.ModuleType | None = None, + ) -> ModuleSpec | None: + if self.should_instrument(fullname): + spec = self._original_pathfinder.find_spec(fullname, path, target) + if spec is not None and isinstance(spec.loader, SourceFileLoader): + spec.loader = TypeguardLoader(spec.loader.name, spec.loader.path) + return spec + + return None + + def should_instrument(self, module_name: str) -> bool: + """ + Determine whether the module with the given name should be instrumented. + + :param module_name: full name of the module that is about to be imported (e.g. + ``xyz.abc``) + + """ + if self.packages is None: + return True + + for package in self.packages: + if module_name == package or module_name.startswith(package + "."): + return True + + return False + + +class ImportHookManager: + """ + A handle that can be used to uninstall the Typeguard import hook. + """ + + def __init__(self, hook: MetaPathFinder): + self.hook = hook + + def __enter__(self) -> None: + pass + + def __exit__( + self, + exc_type: type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> None: + self.uninstall() + + def uninstall(self) -> None: + """Uninstall the import hook.""" + try: + sys.meta_path.remove(self.hook) + except ValueError: + pass # already removed + + +def install_import_hook( + packages: Iterable[str] | None = None, + *, + cls: type[TypeguardFinder] = TypeguardFinder, +) -> ImportHookManager: + """ + Install an import hook that instruments functions for automatic type checking. + + This only affects modules loaded **after** this hook has been installed. + + :param packages: an iterable of package names to instrument, or ``None`` to + instrument all packages + :param cls: a custom meta path finder class + :return: a context manager that uninstalls the hook on exit (or when you call + ``.uninstall()``) + + .. versionadded:: 2.6 + + """ + if packages is None: + target_packages: list[str] | None = None + elif isinstance(packages, str): + target_packages = [packages] + else: + target_packages = list(packages) + + for finder in sys.meta_path: + if ( + isclass(finder) + and finder.__name__ == "PathFinder" + and hasattr(finder, "find_spec") + ): + break + else: + raise RuntimeError("Cannot find a PathFinder in sys.meta_path") + + hook = cls(target_packages, finder) + sys.meta_path.insert(0, hook) + return ImportHookManager(hook) diff --git a/src/typeguard/_memo.py b/src/typeguard/_memo.py new file mode 100644 index 0000000..1d0d80c --- /dev/null +++ b/src/typeguard/_memo.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import Any + +from typeguard._config import TypeCheckConfiguration, global_config + + +class TypeCheckMemo: + """ + Contains information necessary for type checkers to do their work. + + .. attribute:: globals + :type: dict[str, Any] + + Dictionary of global variables to use for resolving forward references. + + .. attribute:: locals + :type: dict[str, Any] + + Dictionary of local variables to use for resolving forward references. + + .. attribute:: self_type + :type: type | None + + When running type checks within an instance method or class method, this is the + class object that the first argument (usually named ``self`` or ``cls``) refers + to. + + .. attribute:: config + :type: TypeCheckConfiguration + + Contains the configuration for a particular set of type checking operations. + """ + + __slots__ = "globals", "locals", "self_type", "config" + + def __init__( + self, + globals: dict[str, Any], + locals: dict[str, Any], + *, + self_type: type | None = None, + config: TypeCheckConfiguration = global_config, + ): + self.globals = globals + self.locals = locals + self.self_type = self_type + self.config = config diff --git a/src/typeguard/_pytest_plugin.py b/src/typeguard/_pytest_plugin.py new file mode 100644 index 0000000..7b2f494 --- /dev/null +++ b/src/typeguard/_pytest_plugin.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import sys +import warnings +from typing import TYPE_CHECKING, Any, Literal + +from typeguard._config import CollectionCheckStrategy, ForwardRefPolicy, global_config +from typeguard._exceptions import InstrumentationWarning +from typeguard._importhook import install_import_hook +from typeguard._utils import qualified_name, resolve_reference + +if TYPE_CHECKING: + from pytest import Config, Parser + + +def pytest_addoption(parser: Parser) -> None: + def add_ini_option( + opt_type: ( + Literal["string", "paths", "pathlist", "args", "linelist", "bool"] | None + ), + ) -> None: + parser.addini( + group.options[-1].names()[0][2:], + group.options[-1].attrs()["help"], + opt_type, + ) + + group = parser.getgroup("typeguard") + group.addoption( + "--typeguard-packages", + action="store", + help="comma separated name list of packages and modules to instrument for " + "type checking, or :all: to instrument all modules loaded after typeguard", + ) + add_ini_option("linelist") + + group.addoption( + "--typeguard-debug-instrumentation", + action="store_true", + help="print all instrumented code to stderr", + ) + add_ini_option("bool") + + group.addoption( + "--typeguard-typecheck-fail-callback", + action="store", + help=( + "a module:varname (e.g. typeguard:warn_on_error) reference to a function " + "that is called (with the exception, and memo object as arguments) to " + "handle a TypeCheckError" + ), + ) + add_ini_option("string") + + group.addoption( + "--typeguard-forward-ref-policy", + action="store", + choices=list(ForwardRefPolicy.__members__), + help=( + "determines how to deal with unresolveable forward references in type " + "annotations" + ), + ) + add_ini_option("string") + + group.addoption( + "--typeguard-collection-check-strategy", + action="store", + choices=list(CollectionCheckStrategy.__members__), + help="determines how thoroughly to check collections (list, dict, etc)", + ) + add_ini_option("string") + + +def pytest_configure(config: Config) -> None: + def getoption(name: str) -> Any: + return config.getoption(name.replace("-", "_")) or config.getini(name) + + packages: list[str] | None = [] + if packages_option := config.getoption("typeguard_packages"): + packages = [pkg.strip() for pkg in packages_option.split(",")] + elif packages_ini := config.getini("typeguard-packages"): + packages = packages_ini + + if packages: + if packages == [":all:"]: + packages = None + else: + already_imported_packages = sorted( + package for package in packages if package in sys.modules + ) + if already_imported_packages: + warnings.warn( + f"typeguard cannot check these packages because they are already " + f"imported: {', '.join(already_imported_packages)}", + InstrumentationWarning, + stacklevel=1, + ) + + install_import_hook(packages=packages) + + debug_option = getoption("typeguard-debug-instrumentation") + if debug_option: + global_config.debug_instrumentation = True + + fail_callback_option = getoption("typeguard-typecheck-fail-callback") + if fail_callback_option: + callback = resolve_reference(fail_callback_option) + if not callable(callback): + raise TypeError( + f"{fail_callback_option} ({qualified_name(callback.__class__)}) is not " + f"a callable" + ) + + global_config.typecheck_fail_callback = callback + + forward_ref_policy_option = getoption("typeguard-forward-ref-policy") + if forward_ref_policy_option: + forward_ref_policy = ForwardRefPolicy.__members__[forward_ref_policy_option] + global_config.forward_ref_policy = forward_ref_policy + + collection_check_strategy_option = getoption("typeguard-collection-check-strategy") + if collection_check_strategy_option: + collection_check_strategy = CollectionCheckStrategy.__members__[ + collection_check_strategy_option + ] + global_config.collection_check_strategy = collection_check_strategy diff --git a/src/typeguard/_suppression.py b/src/typeguard/_suppression.py new file mode 100644 index 0000000..bbbfbfb --- /dev/null +++ b/src/typeguard/_suppression.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import sys +from collections.abc import Callable, Generator +from contextlib import contextmanager +from functools import update_wrapper +from threading import Lock +from typing import ContextManager, TypeVar, overload + +if sys.version_info >= (3, 10): + from typing import ParamSpec +else: + from typing_extensions import ParamSpec + +P = ParamSpec("P") +T = TypeVar("T") + +type_checks_suppressed = 0 +type_checks_suppress_lock = Lock() + + +@overload +def suppress_type_checks(func: Callable[P, T]) -> Callable[P, T]: ... + + +@overload +def suppress_type_checks() -> ContextManager[None]: ... + + +def suppress_type_checks( + func: Callable[P, T] | None = None, +) -> Callable[P, T] | ContextManager[None]: + """ + Temporarily suppress all type checking. + + This function has two operating modes, based on how it's used: + + #. as a context manager (``with suppress_type_checks(): ...``) + #. as a decorator (``@suppress_type_checks``) + + When used as a context manager, :func:`check_type` and any automatically + instrumented functions skip the actual type checking. These context managers can be + nested. + + When used as a decorator, all type checking is suppressed while the function is + running. + + Type checking will resume once no more context managers are active and no decorated + functions are running. + + Both operating modes are thread-safe. + + """ + + def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + global type_checks_suppressed + + with type_checks_suppress_lock: + type_checks_suppressed += 1 + + assert func is not None + try: + return func(*args, **kwargs) + finally: + with type_checks_suppress_lock: + type_checks_suppressed -= 1 + + def cm() -> Generator[None, None, None]: + global type_checks_suppressed + + with type_checks_suppress_lock: + type_checks_suppressed += 1 + + try: + yield + finally: + with type_checks_suppress_lock: + type_checks_suppressed -= 1 + + if func is None: + # Context manager mode + return contextmanager(cm)() + else: + # Decorator mode + update_wrapper(wrapper, func) + return wrapper diff --git a/src/typeguard/_transformer.py b/src/typeguard/_transformer.py new file mode 100644 index 0000000..25696a5 --- /dev/null +++ b/src/typeguard/_transformer.py @@ -0,0 +1,1214 @@ +from __future__ import annotations + +import ast +import builtins +import sys +import typing +from ast import ( + AST, + Add, + AnnAssign, + Assign, + AsyncFunctionDef, + Attribute, + AugAssign, + BinOp, + BitAnd, + BitOr, + BitXor, + Call, + ClassDef, + Constant, + Dict, + Div, + Expr, + Expression, + FloorDiv, + FunctionDef, + If, + Import, + ImportFrom, + List, + Load, + LShift, + MatMult, + Mod, + Module, + Mult, + Name, + NamedExpr, + NodeTransformer, + NodeVisitor, + Pass, + Pow, + Return, + RShift, + Starred, + Store, + Sub, + Subscript, + Tuple, + Yield, + YieldFrom, + alias, + copy_location, + expr, + fix_missing_locations, + keyword, + walk, +) +from collections import defaultdict +from collections.abc import Generator, Sequence +from contextlib import contextmanager +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, ClassVar, cast, overload + +generator_names = ( + "typing.Generator", + "collections.abc.Generator", + "typing.Iterator", + "collections.abc.Iterator", + "typing.Iterable", + "collections.abc.Iterable", + "typing.AsyncIterator", + "collections.abc.AsyncIterator", + "typing.AsyncIterable", + "collections.abc.AsyncIterable", + "typing.AsyncGenerator", + "collections.abc.AsyncGenerator", +) +anytype_names = ( + "typing.Any", + "typing_extensions.Any", +) +literal_names = ( + "typing.Literal", + "typing_extensions.Literal", +) +annotated_names = ( + "typing.Annotated", + "typing_extensions.Annotated", +) +ignore_decorators = ( + "typing.no_type_check", + "typeguard.typeguard_ignore", +) +aug_assign_functions = { + Add: "iadd", + Sub: "isub", + Mult: "imul", + MatMult: "imatmul", + Div: "itruediv", + FloorDiv: "ifloordiv", + Mod: "imod", + Pow: "ipow", + LShift: "ilshift", + RShift: "irshift", + BitAnd: "iand", + BitXor: "ixor", + BitOr: "ior", +} + + +@dataclass +class TransformMemo: + node: Module | ClassDef | FunctionDef | AsyncFunctionDef | None + parent: TransformMemo | None + path: tuple[str, ...] + joined_path: Constant = field(init=False) + return_annotation: expr | None = None + yield_annotation: expr | None = None + send_annotation: expr | None = None + is_async: bool = False + local_names: set[str] = field(init=False, default_factory=set) + imported_names: dict[str, str] = field(init=False, default_factory=dict) + ignored_names: set[str] = field(init=False, default_factory=set) + load_names: defaultdict[str, dict[str, Name]] = field( + init=False, default_factory=lambda: defaultdict(dict) + ) + has_yield_expressions: bool = field(init=False, default=False) + has_return_expressions: bool = field(init=False, default=False) + memo_var_name: Name | None = field(init=False, default=None) + should_instrument: bool = field(init=False, default=True) + variable_annotations: dict[str, expr] = field(init=False, default_factory=dict) + configuration_overrides: dict[str, Any] = field(init=False, default_factory=dict) + code_inject_index: int = field(init=False, default=0) + + def __post_init__(self) -> None: + elements: list[str] = [] + memo = self + while isinstance(memo.node, (ClassDef, FunctionDef, AsyncFunctionDef)): + elements.insert(0, memo.node.name) + if not memo.parent: + break + + memo = memo.parent + if isinstance(memo.node, (FunctionDef, AsyncFunctionDef)): + elements.insert(0, "") + + self.joined_path = Constant(".".join(elements)) + + # Figure out where to insert instrumentation code + if self.node: + for index, child in enumerate(self.node.body): + if isinstance(child, ImportFrom) and child.module == "__future__": + # (module only) __future__ imports must come first + continue + elif ( + isinstance(child, Expr) + and isinstance(child.value, Constant) + and isinstance(child.value.value, str) + ): + continue # docstring + + self.code_inject_index = index + break + + def get_unused_name(self, name: str) -> str: + memo: TransformMemo | None = self + while memo is not None: + if name in memo.local_names: + memo = self + name += "_" + else: + memo = memo.parent + + self.local_names.add(name) + return name + + def is_ignored_name(self, expression: expr | Expr | None) -> bool: + top_expression = ( + expression.value if isinstance(expression, Expr) else expression + ) + + if isinstance(top_expression, Attribute) and isinstance( + top_expression.value, Name + ): + name = top_expression.value.id + elif isinstance(top_expression, Name): + name = top_expression.id + else: + return False + + memo: TransformMemo | None = self + while memo is not None: + if name in memo.ignored_names: + return True + + memo = memo.parent + + return False + + def get_memo_name(self) -> Name: + if not self.memo_var_name: + self.memo_var_name = Name(id="memo", ctx=Load()) + + return self.memo_var_name + + def get_import(self, module: str, name: str) -> Name: + if module in self.load_names and name in self.load_names[module]: + return self.load_names[module][name] + + qualified_name = f"{module}.{name}" + if name in self.imported_names and self.imported_names[name] == qualified_name: + return Name(id=name, ctx=Load()) + + alias = self.get_unused_name(name) + node = self.load_names[module][name] = Name(id=alias, ctx=Load()) + self.imported_names[name] = qualified_name + return node + + def insert_imports(self, node: Module | FunctionDef | AsyncFunctionDef) -> None: + """Insert imports needed by injected code.""" + if not self.load_names: + return + + # Insert imports after any "from __future__ ..." imports and any docstring + for modulename, names in self.load_names.items(): + aliases = [ + alias(orig_name, new_name.id if orig_name != new_name.id else None) + for orig_name, new_name in sorted(names.items()) + ] + node.body.insert(self.code_inject_index, ImportFrom(modulename, aliases, 0)) + + def name_matches(self, expression: expr | Expr | None, *names: str) -> bool: + if expression is None: + return False + + path: list[str] = [] + top_expression = ( + expression.value if isinstance(expression, Expr) else expression + ) + + if isinstance(top_expression, Subscript): + top_expression = top_expression.value + elif isinstance(top_expression, Call): + top_expression = top_expression.func + + while isinstance(top_expression, Attribute): + path.insert(0, top_expression.attr) + top_expression = top_expression.value + + if not isinstance(top_expression, Name): + return False + + if top_expression.id in self.imported_names: + translated = self.imported_names[top_expression.id] + elif hasattr(builtins, top_expression.id): + translated = "builtins." + top_expression.id + else: + translated = top_expression.id + + path.insert(0, translated) + joined_path = ".".join(path) + if joined_path in names: + return True + elif self.parent: + return self.parent.name_matches(expression, *names) + else: + return False + + def get_config_keywords(self) -> list[keyword]: + if self.parent and isinstance(self.parent.node, ClassDef): + overrides = self.parent.configuration_overrides.copy() + else: + overrides = {} + + overrides.update(self.configuration_overrides) + return [keyword(key, value) for key, value in overrides.items()] + + +class NameCollector(NodeVisitor): + def __init__(self) -> None: + self.names: set[str] = set() + + def visit_Import(self, node: Import) -> None: + for name in node.names: + self.names.add(name.asname or name.name) + + def visit_ImportFrom(self, node: ImportFrom) -> None: + for name in node.names: + self.names.add(name.asname or name.name) + + def visit_Assign(self, node: Assign) -> None: + for target in node.targets: + if isinstance(target, Name): + self.names.add(target.id) + + def visit_NamedExpr(self, node: NamedExpr) -> Any: + if isinstance(node.target, Name): + self.names.add(node.target.id) + + def visit_FunctionDef(self, node: FunctionDef) -> None: + pass + + def visit_ClassDef(self, node: ClassDef) -> None: + pass + + +class GeneratorDetector(NodeVisitor): + """Detects if a function node is a generator function.""" + + contains_yields: bool = False + in_root_function: bool = False + + def visit_Yield(self, node: Yield) -> Any: + self.contains_yields = True + + def visit_YieldFrom(self, node: YieldFrom) -> Any: + self.contains_yields = True + + def visit_ClassDef(self, node: ClassDef) -> Any: + pass + + def visit_FunctionDef(self, node: FunctionDef | AsyncFunctionDef) -> Any: + if not self.in_root_function: + self.in_root_function = True + self.generic_visit(node) + self.in_root_function = False + + def visit_AsyncFunctionDef(self, node: AsyncFunctionDef) -> Any: + self.visit_FunctionDef(node) + + +class AnnotationTransformer(NodeTransformer): + type_substitutions: ClassVar[dict[str, tuple[str, str]]] = { + "builtins.dict": ("typing", "Dict"), + "builtins.list": ("typing", "List"), + "builtins.tuple": ("typing", "Tuple"), + "builtins.set": ("typing", "Set"), + "builtins.frozenset": ("typing", "FrozenSet"), + } + + def __init__(self, transformer: TypeguardTransformer): + self.transformer = transformer + self._memo = transformer._memo + self._level = 0 + + def visit(self, node: AST) -> Any: + # Don't process Literals + if isinstance(node, expr) and self._memo.name_matches(node, *literal_names): + return node + + self._level += 1 + new_node = super().visit(node) + self._level -= 1 + + if isinstance(new_node, Expression) and not hasattr(new_node, "body"): + return None + + # Return None if this new node matches a variation of typing.Any + if ( + self._level == 0 + and isinstance(new_node, expr) + and self._memo.name_matches(new_node, *anytype_names) + ): + return None + + return new_node + + def visit_BinOp(self, node: BinOp) -> Any: + self.generic_visit(node) + + if isinstance(node.op, BitOr): + # If either branch of the BinOp has been transformed to `None`, it means + # that a type in the union was ignored, so the entire annotation should e + # ignored + if not hasattr(node, "left") or not hasattr(node, "right"): + return None + + # Return Any if either side is Any + if self._memo.name_matches(node.left, *anytype_names): + return node.left + elif self._memo.name_matches(node.right, *anytype_names): + return node.right + + if sys.version_info < (3, 10): + union_name = self.transformer._get_import("typing", "Union") + return Subscript( + value=union_name, + slice=Tuple(elts=[node.left, node.right], ctx=Load()), + ctx=Load(), + ) + + return node + + def visit_Attribute(self, node: Attribute) -> Any: + if self._memo.is_ignored_name(node): + return None + + return node + + def visit_Subscript(self, node: Subscript) -> Any: + if self._memo.is_ignored_name(node.value): + return None + + # The subscript of typing(_extensions).Literal can be any arbitrary string, so + # don't try to evaluate it as code + if node.slice: + if isinstance(node.slice, Tuple): + if self._memo.name_matches(node.value, *annotated_names): + # Only treat the first argument to typing.Annotated as a potential + # forward reference + items = cast( + typing.List[expr], + [self.visit(node.slice.elts[0])] + node.slice.elts[1:], + ) + else: + items = cast( + typing.List[expr], + [self.visit(item) for item in node.slice.elts], + ) + + # If this is a Union and any of the items is Any, erase the entire + # annotation + if self._memo.name_matches(node.value, "typing.Union") and any( + item is None + or ( + isinstance(item, expr) + and self._memo.name_matches(item, *anytype_names) + ) + for item in items + ): + return None + + # If all items in the subscript were Any, erase the subscript entirely + if all(item is None for item in items): + return node.value + + for index, item in enumerate(items): + if item is None: + items[index] = self.transformer._get_import("typing", "Any") + + node.slice.elts = items + else: + self.generic_visit(node) + + # If the transformer erased the slice entirely, just return the node + # value without the subscript (unless it's Optional, in which case erase + # the node entirely + if self._memo.name_matches( + node.value, "typing.Optional" + ) and not hasattr(node, "slice"): + return None + if sys.version_info >= (3, 9) and not hasattr(node, "slice"): + return node.value + elif sys.version_info < (3, 9) and not hasattr(node.slice, "value"): + return node.value + + return node + + def visit_Name(self, node: Name) -> Any: + if self._memo.is_ignored_name(node): + return None + + return node + + def visit_Call(self, node: Call) -> Any: + # Don't recurse into calls + return node + + def visit_Constant(self, node: Constant) -> Any: + if isinstance(node.value, str): + expression = ast.parse(node.value, mode="eval") + new_node = self.visit(expression) + if new_node: + return copy_location(new_node.body, node) + else: + return None + + return node + + +class TypeguardTransformer(NodeTransformer): + def __init__( + self, target_path: Sequence[str] | None = None, target_lineno: int | None = None + ) -> None: + self._target_path = tuple(target_path) if target_path else None + self._memo = self._module_memo = TransformMemo(None, None, ()) + self.names_used_in_annotations: set[str] = set() + self.target_node: FunctionDef | AsyncFunctionDef | None = None + self.target_lineno = target_lineno + + def generic_visit(self, node: AST) -> AST: + has_non_empty_body_initially = bool(getattr(node, "body", None)) + initial_type = type(node) + + node = super().generic_visit(node) + + if ( + type(node) is initial_type + and has_non_empty_body_initially + and hasattr(node, "body") + and not node.body + ): + # If we have still the same node type after transformation + # but we've optimised it's body away, we add a `pass` statement. + node.body = [Pass()] + + return node + + @contextmanager + def _use_memo( + self, node: ClassDef | FunctionDef | AsyncFunctionDef + ) -> Generator[None, Any, None]: + new_memo = TransformMemo(node, self._memo, self._memo.path + (node.name,)) + old_memo = self._memo + self._memo = new_memo + + if isinstance(node, (FunctionDef, AsyncFunctionDef)): + new_memo.should_instrument = ( + self._target_path is None or new_memo.path == self._target_path + ) + if new_memo.should_instrument: + # Check if the function is a generator function + detector = GeneratorDetector() + detector.visit(node) + + # Extract yield, send and return types where possible from a subscripted + # annotation like Generator[int, str, bool] + return_annotation = deepcopy(node.returns) + if detector.contains_yields and new_memo.name_matches( + return_annotation, *generator_names + ): + if isinstance(return_annotation, Subscript): + if isinstance(return_annotation.slice, Tuple): + items = return_annotation.slice.elts + else: + items = [return_annotation.slice] + + if len(items) > 0: + new_memo.yield_annotation = self._convert_annotation( + items[0] + ) + + if len(items) > 1: + new_memo.send_annotation = self._convert_annotation( + items[1] + ) + + if len(items) > 2: + new_memo.return_annotation = self._convert_annotation( + items[2] + ) + else: + new_memo.return_annotation = self._convert_annotation( + return_annotation + ) + + if isinstance(node, AsyncFunctionDef): + new_memo.is_async = True + + yield + self._memo = old_memo + + def _get_import(self, module: str, name: str) -> Name: + memo = self._memo if self._target_path else self._module_memo + return memo.get_import(module, name) + + @overload + def _convert_annotation(self, annotation: None) -> None: ... + + @overload + def _convert_annotation(self, annotation: expr) -> expr: ... + + def _convert_annotation(self, annotation: expr | None) -> expr | None: + if annotation is None: + return None + + # Convert PEP 604 unions (x | y) and generic built-in collections where + # necessary, and undo forward references + new_annotation = cast(expr, AnnotationTransformer(self).visit(annotation)) + if isinstance(new_annotation, expr): + new_annotation = ast.copy_location(new_annotation, annotation) + + # Store names used in the annotation + names = {node.id for node in walk(new_annotation) if isinstance(node, Name)} + self.names_used_in_annotations.update(names) + + return new_annotation + + def visit_Name(self, node: Name) -> Name: + self._memo.local_names.add(node.id) + return node + + def visit_Module(self, node: Module) -> Module: + self._module_memo = self._memo = TransformMemo(node, None, ()) + self.generic_visit(node) + self._module_memo.insert_imports(node) + + fix_missing_locations(node) + return node + + def visit_Import(self, node: Import) -> Import: + for name in node.names: + self._memo.local_names.add(name.asname or name.name) + self._memo.imported_names[name.asname or name.name] = name.name + + return node + + def visit_ImportFrom(self, node: ImportFrom) -> ImportFrom: + for name in node.names: + if name.name != "*": + alias = name.asname or name.name + self._memo.local_names.add(alias) + self._memo.imported_names[alias] = f"{node.module}.{name.name}" + + return node + + def visit_ClassDef(self, node: ClassDef) -> ClassDef | None: + self._memo.local_names.add(node.name) + + # Eliminate top level classes not belonging to the target path + if ( + self._target_path is not None + and not self._memo.path + and node.name != self._target_path[0] + ): + return None + + with self._use_memo(node): + for decorator in node.decorator_list.copy(): + if self._memo.name_matches(decorator, "typeguard.typechecked"): + # Remove the decorator to prevent duplicate instrumentation + node.decorator_list.remove(decorator) + + # Store any configuration overrides + if isinstance(decorator, Call) and decorator.keywords: + self._memo.configuration_overrides.update( + {kw.arg: kw.value for kw in decorator.keywords if kw.arg} + ) + + self.generic_visit(node) + return node + + def visit_FunctionDef( + self, node: FunctionDef | AsyncFunctionDef + ) -> FunctionDef | AsyncFunctionDef | None: + """ + Injects type checks for function arguments, and for a return of None if the + function is annotated to return something else than Any or None, and the body + ends without an explicit "return". + + """ + self._memo.local_names.add(node.name) + + # Eliminate top level functions not belonging to the target path + if ( + self._target_path is not None + and not self._memo.path + and node.name != self._target_path[0] + ): + return None + + # Skip instrumentation if we're instrumenting the whole module and the function + # contains either @no_type_check or @typeguard_ignore + if self._target_path is None: + for decorator in node.decorator_list: + if self._memo.name_matches(decorator, *ignore_decorators): + return node + + with self._use_memo(node): + arg_annotations: dict[str, Any] = {} + if self._target_path is None or self._memo.path == self._target_path: + # Find line number we're supposed to match against + if node.decorator_list: + first_lineno = node.decorator_list[0].lineno + else: + first_lineno = node.lineno + + for decorator in node.decorator_list.copy(): + if self._memo.name_matches(decorator, "typing.overload"): + # Remove overloads entirely + return None + elif self._memo.name_matches(decorator, "typeguard.typechecked"): + # Remove the decorator to prevent duplicate instrumentation + node.decorator_list.remove(decorator) + + # Store any configuration overrides + if isinstance(decorator, Call) and decorator.keywords: + self._memo.configuration_overrides = { + kw.arg: kw.value for kw in decorator.keywords if kw.arg + } + + if self.target_lineno == first_lineno: + assert self.target_node is None + self.target_node = node + if node.decorator_list: + self.target_lineno = node.decorator_list[0].lineno + else: + self.target_lineno = node.lineno + + all_args = node.args.posonlyargs + node.args.args + node.args.kwonlyargs + + # Ensure that any type shadowed by the positional or keyword-only + # argument names are ignored in this function + for arg in all_args: + self._memo.ignored_names.add(arg.arg) + + # Ensure that any type shadowed by the variable positional argument name + # (e.g. "args" in *args) is ignored this function + if node.args.vararg: + self._memo.ignored_names.add(node.args.vararg.arg) + + # Ensure that any type shadowed by the variable keywrod argument name + # (e.g. "kwargs" in *kwargs) is ignored this function + if node.args.kwarg: + self._memo.ignored_names.add(node.args.kwarg.arg) + + for arg in all_args: + annotation = self._convert_annotation(deepcopy(arg.annotation)) + if annotation: + arg_annotations[arg.arg] = annotation + + if node.args.vararg: + annotation_ = self._convert_annotation(node.args.vararg.annotation) + if annotation_: + container = Name("tuple", ctx=Load()) + subscript_slice = Tuple( + [ + annotation_, + Constant(Ellipsis), + ], + ctx=Load(), + ) + arg_annotations[node.args.vararg.arg] = Subscript( + container, subscript_slice, ctx=Load() + ) + + if node.args.kwarg: + annotation_ = self._convert_annotation(node.args.kwarg.annotation) + if annotation_: + container = Name("dict", ctx=Load()) + subscript_slice = Tuple( + [ + Name("str", ctx=Load()), + annotation_, + ], + ctx=Load(), + ) + arg_annotations[node.args.kwarg.arg] = Subscript( + container, subscript_slice, ctx=Load() + ) + + if arg_annotations: + self._memo.variable_annotations.update(arg_annotations) + + self.generic_visit(node) + + if arg_annotations: + annotations_dict = Dict( + keys=[Constant(key) for key in arg_annotations.keys()], + values=[ + Tuple([Name(key, ctx=Load()), annotation], ctx=Load()) + for key, annotation in arg_annotations.items() + ], + ) + func_name = self._get_import( + "typeguard._functions", "check_argument_types" + ) + args = [ + self._memo.joined_path, + annotations_dict, + self._memo.get_memo_name(), + ] + node.body.insert( + self._memo.code_inject_index, Expr(Call(func_name, args, [])) + ) + + # Add a checked "return None" to the end if there's no explicit return + # Skip if the return annotation is None or Any + if ( + self._memo.return_annotation + and (not self._memo.is_async or not self._memo.has_yield_expressions) + and not isinstance(node.body[-1], Return) + and ( + not isinstance(self._memo.return_annotation, Constant) + or self._memo.return_annotation.value is not None + ) + ): + func_name = self._get_import( + "typeguard._functions", "check_return_type" + ) + return_node = Return( + Call( + func_name, + [ + self._memo.joined_path, + Constant(None), + self._memo.return_annotation, + self._memo.get_memo_name(), + ], + [], + ) + ) + + # Replace a placeholder "pass" at the end + if isinstance(node.body[-1], Pass): + copy_location(return_node, node.body[-1]) + del node.body[-1] + + node.body.append(return_node) + + # Insert code to create the call memo, if it was ever needed for this + # function + if self._memo.memo_var_name: + memo_kwargs: dict[str, Any] = {} + if self._memo.parent and isinstance(self._memo.parent.node, ClassDef): + for decorator in node.decorator_list: + if ( + isinstance(decorator, Name) + and decorator.id == "staticmethod" + ): + break + elif ( + isinstance(decorator, Name) + and decorator.id == "classmethod" + ): + arglist = node.args.posonlyargs or node.args.args + memo_kwargs["self_type"] = Name( + id=arglist[0].arg, ctx=Load() + ) + break + else: + if arglist := node.args.posonlyargs or node.args.args: + if node.name == "__new__": + memo_kwargs["self_type"] = Name( + id=arglist[0].arg, ctx=Load() + ) + else: + memo_kwargs["self_type"] = Attribute( + Name(id=arglist[0].arg, ctx=Load()), + "__class__", + ctx=Load(), + ) + + # Construct the function reference + # Nested functions get special treatment: the function name is added + # to free variables (and the closure of the resulting function) + names: list[str] = [node.name] + memo = self._memo.parent + while memo: + if isinstance(memo.node, (FunctionDef, AsyncFunctionDef)): + # This is a nested function. Use the function name as-is. + del names[:-1] + break + elif not isinstance(memo.node, ClassDef): + break + + names.insert(0, memo.node.name) + memo = memo.parent + + config_keywords = self._memo.get_config_keywords() + if config_keywords: + memo_kwargs["config"] = Call( + self._get_import("dataclasses", "replace"), + [self._get_import("typeguard._config", "global_config")], + config_keywords, + ) + + self._memo.memo_var_name.id = self._memo.get_unused_name("memo") + memo_store_name = Name(id=self._memo.memo_var_name.id, ctx=Store()) + globals_call = Call(Name(id="globals", ctx=Load()), [], []) + locals_call = Call(Name(id="locals", ctx=Load()), [], []) + memo_expr = Call( + self._get_import("typeguard", "TypeCheckMemo"), + [globals_call, locals_call], + [keyword(key, value) for key, value in memo_kwargs.items()], + ) + node.body.insert( + self._memo.code_inject_index, + Assign([memo_store_name], memo_expr), + ) + + self._memo.insert_imports(node) + + # Special case the __new__() method to create a local alias from the + # class name to the first argument (usually "cls") + if ( + isinstance(node, FunctionDef) + and node.args + and self._memo.parent is not None + and isinstance(self._memo.parent.node, ClassDef) + and node.name == "__new__" + ): + first_args_expr = Name(node.args.args[0].arg, ctx=Load()) + cls_name = Name(self._memo.parent.node.name, ctx=Store()) + node.body.insert( + self._memo.code_inject_index, + Assign([cls_name], first_args_expr), + ) + + # Rmove any placeholder "pass" at the end + if isinstance(node.body[-1], Pass): + del node.body[-1] + + return node + + def visit_AsyncFunctionDef( + self, node: AsyncFunctionDef + ) -> FunctionDef | AsyncFunctionDef | None: + return self.visit_FunctionDef(node) + + def visit_Return(self, node: Return) -> Return: + """This injects type checks into "return" statements.""" + self.generic_visit(node) + if ( + self._memo.return_annotation + and self._memo.should_instrument + and not self._memo.is_ignored_name(self._memo.return_annotation) + ): + func_name = self._get_import("typeguard._functions", "check_return_type") + old_node = node + retval = old_node.value or Constant(None) + node = Return( + Call( + func_name, + [ + self._memo.joined_path, + retval, + self._memo.return_annotation, + self._memo.get_memo_name(), + ], + [], + ) + ) + copy_location(node, old_node) + + return node + + def visit_Yield(self, node: Yield) -> Yield | Call: + """ + This injects type checks into "yield" expressions, checking both the yielded + value and the value sent back to the generator, when appropriate. + + """ + self._memo.has_yield_expressions = True + self.generic_visit(node) + + if ( + self._memo.yield_annotation + and self._memo.should_instrument + and not self._memo.is_ignored_name(self._memo.yield_annotation) + ): + func_name = self._get_import("typeguard._functions", "check_yield_type") + yieldval = node.value or Constant(None) + node.value = Call( + func_name, + [ + self._memo.joined_path, + yieldval, + self._memo.yield_annotation, + self._memo.get_memo_name(), + ], + [], + ) + + if ( + self._memo.send_annotation + and self._memo.should_instrument + and not self._memo.is_ignored_name(self._memo.send_annotation) + ): + func_name = self._get_import("typeguard._functions", "check_send_type") + old_node = node + call_node = Call( + func_name, + [ + self._memo.joined_path, + old_node, + self._memo.send_annotation, + self._memo.get_memo_name(), + ], + [], + ) + copy_location(call_node, old_node) + return call_node + + return node + + def visit_AnnAssign(self, node: AnnAssign) -> Any: + """ + This injects a type check into a local variable annotation-assignment within a + function body. + + """ + self.generic_visit(node) + + if ( + isinstance(self._memo.node, (FunctionDef, AsyncFunctionDef)) + and node.annotation + and isinstance(node.target, Name) + ): + self._memo.ignored_names.add(node.target.id) + annotation = self._convert_annotation(deepcopy(node.annotation)) + if annotation: + self._memo.variable_annotations[node.target.id] = annotation + if node.value: + func_name = self._get_import( + "typeguard._functions", "check_variable_assignment" + ) + targets_arg = List( + [ + List( + [ + Tuple( + [Constant(node.target.id), annotation], + ctx=Load(), + ) + ], + ctx=Load(), + ) + ], + ctx=Load(), + ) + node.value = Call( + func_name, + [ + node.value, + targets_arg, + self._memo.get_memo_name(), + ], + [], + ) + + return node + + def visit_Assign(self, node: Assign) -> Any: + """ + This injects a type check into a local variable assignment within a function + body. The variable must have been annotated earlier in the function body. + + """ + self.generic_visit(node) + + # Only instrument function-local assignments + if isinstance(self._memo.node, (FunctionDef, AsyncFunctionDef)): + preliminary_targets: list[list[tuple[Constant, expr | None]]] = [] + check_required = False + for target in node.targets: + elts: Sequence[expr] + if isinstance(target, Name): + elts = [target] + elif isinstance(target, Tuple): + elts = target.elts + else: + continue + + annotations_: list[tuple[Constant, expr | None]] = [] + for exp in elts: + prefix = "" + if isinstance(exp, Starred): + exp = exp.value + prefix = "*" + + path: list[str] = [] + while isinstance(exp, Attribute): + path.insert(0, exp.attr) + exp = exp.value + + if isinstance(exp, Name): + if not path: + self._memo.ignored_names.add(exp.id) + + path.insert(0, exp.id) + name = prefix + ".".join(path) + annotation = self._memo.variable_annotations.get(exp.id) + if annotation: + annotations_.append((Constant(name), annotation)) + check_required = True + else: + annotations_.append((Constant(name), None)) + + preliminary_targets.append(annotations_) + + if check_required: + # Replace missing annotations with typing.Any + targets: list[list[tuple[Constant, expr]]] = [] + for items in preliminary_targets: + target_list: list[tuple[Constant, expr]] = [] + targets.append(target_list) + for key, expression in items: + if expression is None: + target_list.append((key, self._get_import("typing", "Any"))) + else: + target_list.append((key, expression)) + + func_name = self._get_import( + "typeguard._functions", "check_variable_assignment" + ) + targets_arg = List( + [ + List( + [Tuple([name, ann], ctx=Load()) for name, ann in target], + ctx=Load(), + ) + for target in targets + ], + ctx=Load(), + ) + node.value = Call( + func_name, + [node.value, targets_arg, self._memo.get_memo_name()], + [], + ) + + return node + + def visit_NamedExpr(self, node: NamedExpr) -> Any: + """This injects a type check into an assignment expression (a := foo()).""" + self.generic_visit(node) + + # Only instrument function-local assignments + if isinstance(self._memo.node, (FunctionDef, AsyncFunctionDef)) and isinstance( + node.target, Name + ): + self._memo.ignored_names.add(node.target.id) + + # Bail out if no matching annotation is found + annotation = self._memo.variable_annotations.get(node.target.id) + if annotation is None: + return node + + func_name = self._get_import( + "typeguard._functions", "check_variable_assignment" + ) + node.value = Call( + func_name, + [ + node.value, + Constant(node.target.id), + annotation, + self._memo.get_memo_name(), + ], + [], + ) + + return node + + def visit_AugAssign(self, node: AugAssign) -> Any: + """ + This injects a type check into an augmented assignment expression (a += 1). + + """ + self.generic_visit(node) + + # Only instrument function-local assignments + if isinstance(self._memo.node, (FunctionDef, AsyncFunctionDef)) and isinstance( + node.target, Name + ): + # Bail out if no matching annotation is found + annotation = self._memo.variable_annotations.get(node.target.id) + if annotation is None: + return node + + # Bail out if the operator is not found (newer Python version?) + try: + operator_func_name = aug_assign_functions[node.op.__class__] + except KeyError: + return node + + operator_func = self._get_import("operator", operator_func_name) + operator_call = Call( + operator_func, [Name(node.target.id, ctx=Load()), node.value], [] + ) + targets_arg = List( + [ + List( + [Tuple([Constant(node.target.id), annotation], ctx=Load())], + ctx=Load(), + ) + ], + ctx=Load(), + ) + check_call = Call( + self._get_import("typeguard._functions", "check_variable_assignment"), + [ + operator_call, + targets_arg, + self._memo.get_memo_name(), + ], + [], + ) + return Assign(targets=[node.target], value=check_call) + + return node + + def visit_If(self, node: If) -> Any: + """ + This blocks names from being collected from a module-level + "if typing.TYPE_CHECKING:" block, so that they won't be type checked. + + """ + self.generic_visit(node) + + if ( + self._memo is self._module_memo + and isinstance(node.test, Name) + and self._memo.name_matches(node.test, "typing.TYPE_CHECKING") + ): + collector = NameCollector() + collector.visit(node) + self._memo.ignored_names.update(collector.names) + + return node diff --git a/src/typeguard/_union_transformer.py b/src/typeguard/_union_transformer.py new file mode 100644 index 0000000..1c296d3 --- /dev/null +++ b/src/typeguard/_union_transformer.py @@ -0,0 +1,43 @@ +""" +Transforms lazily evaluated PEP 604 unions into typing.Unions, for compatibility with +Python versions older than 3.10. +""" + +from __future__ import annotations + +from ast import ( + BinOp, + BitOr, + Load, + Name, + NodeTransformer, + Subscript, + Tuple, + fix_missing_locations, + parse, +) +from types import CodeType +from typing import Any + + +class UnionTransformer(NodeTransformer): + def __init__(self, union_name: Name | None = None): + self.union_name = union_name or Name(id="Union", ctx=Load()) + + def visit_BinOp(self, node: BinOp) -> Any: + self.generic_visit(node) + if isinstance(node.op, BitOr): + return Subscript( + value=self.union_name, + slice=Tuple(elts=[node.left, node.right], ctx=Load()), + ctx=Load(), + ) + + return node + + +def compile_type_hint(hint: str) -> CodeType: + parsed = parse(hint, "", "eval") + UnionTransformer().visit(parsed) + fix_missing_locations(parsed) + return compile(parsed, "", "eval", flags=0) diff --git a/src/typeguard/_utils.py b/src/typeguard/_utils.py new file mode 100644 index 0000000..e8f9b03 --- /dev/null +++ b/src/typeguard/_utils.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import inspect +import sys +from importlib import import_module +from inspect import currentframe +from types import CodeType, FrameType, FunctionType +from typing import TYPE_CHECKING, Any, Callable, ForwardRef, Union, cast, final +from weakref import WeakValueDictionary + +if TYPE_CHECKING: + from ._memo import TypeCheckMemo + +if sys.version_info >= (3, 13): + from typing import get_args, get_origin + + def evaluate_forwardref(forwardref: ForwardRef, memo: TypeCheckMemo) -> Any: + return forwardref._evaluate( + memo.globals, memo.locals, type_params=(), recursive_guard=frozenset() + ) + +elif sys.version_info >= (3, 10): + from typing import get_args, get_origin + + def evaluate_forwardref(forwardref: ForwardRef, memo: TypeCheckMemo) -> Any: + return forwardref._evaluate( + memo.globals, memo.locals, recursive_guard=frozenset() + ) + +else: + from typing_extensions import get_args, get_origin + + evaluate_extra_args: tuple[frozenset[Any], ...] = ( + (frozenset(),) if sys.version_info >= (3, 9) else () + ) + + def evaluate_forwardref(forwardref: ForwardRef, memo: TypeCheckMemo) -> Any: + from ._union_transformer import compile_type_hint + + if not forwardref.__forward_evaluated__: + forwardref.__forward_code__ = compile_type_hint(forwardref.__forward_arg__) + + try: + return forwardref._evaluate(memo.globals, memo.locals, *evaluate_extra_args) + except NameError: + if sys.version_info < (3, 10): + # Try again, with the type substitutions (list -> List etc.) in place + new_globals = memo.globals.copy() + new_globals.setdefault("Union", Union) + + return forwardref._evaluate( + new_globals, memo.locals or new_globals, *evaluate_extra_args + ) + + raise + + +_functions_map: WeakValueDictionary[CodeType, FunctionType] = WeakValueDictionary() + + +def get_type_name(type_: Any) -> str: + name: str + for attrname in "__name__", "_name", "__forward_arg__": + candidate = getattr(type_, attrname, None) + if isinstance(candidate, str): + name = candidate + break + else: + origin = get_origin(type_) + candidate = getattr(origin, "_name", None) + if candidate is None: + candidate = type_.__class__.__name__.strip("_") + + if isinstance(candidate, str): + name = candidate + else: + return "(unknown)" + + args = get_args(type_) + if args: + if name == "Literal": + formatted_args = ", ".join(repr(arg) for arg in args) + else: + formatted_args = ", ".join(get_type_name(arg) for arg in args) + + name += f"[{formatted_args}]" + + module = getattr(type_, "__module__", None) + if module and module not in (None, "typing", "typing_extensions", "builtins"): + name = module + "." + name + + return name + + +def qualified_name(obj: Any, *, add_class_prefix: bool = False) -> str: + """ + Return the qualified name (e.g. package.module.Type) for the given object. + + Builtins and types from the :mod:`typing` package get special treatment by having + the module name stripped from the generated name. + + """ + if obj is None: + return "None" + elif inspect.isclass(obj): + prefix = "class " if add_class_prefix else "" + type_ = obj + else: + prefix = "" + type_ = type(obj) + + module = type_.__module__ + qualname = type_.__qualname__ + name = qualname if module in ("typing", "builtins") else f"{module}.{qualname}" + return prefix + name + + +def function_name(func: Callable[..., Any]) -> str: + """ + Return the qualified name of the given function. + + Builtins and types from the :mod:`typing` package get special treatment by having + the module name stripped from the generated name. + + """ + # For partial functions and objects with __call__ defined, __qualname__ does not + # exist + module = getattr(func, "__module__", "") + qualname = (module + ".") if module not in ("builtins", "") else "" + return qualname + getattr(func, "__qualname__", repr(func)) + + +def resolve_reference(reference: str) -> Any: + modulename, varname = reference.partition(":")[::2] + if not modulename or not varname: + raise ValueError(f"{reference!r} is not a module:varname reference") + + obj = import_module(modulename) + for attr in varname.split("."): + obj = getattr(obj, attr) + + return obj + + +def is_method_of(obj: object, cls: type) -> bool: + return ( + inspect.isfunction(obj) + and obj.__module__ == cls.__module__ + and obj.__qualname__.startswith(cls.__qualname__ + ".") + ) + + +def get_stacklevel() -> int: + level = 1 + frame = cast(FrameType, currentframe()).f_back + while frame and frame.f_globals.get("__name__", "").startswith("typeguard."): + level += 1 + frame = frame.f_back + + return level + + +@final +class Unset: + __slots__ = () + + def __repr__(self) -> str: + return "" + + +unset = Unset() diff --git a/src/typeguard/py.typed b/src/typeguard/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b48bd69 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,44 @@ +from typing import ( + AbstractSet, + Collection, + Dict, + Generic, + List, + NamedTuple, + NewType, + TypeVar, + Union, +) + +T_Foo = TypeVar("T_Foo") + +TBound = TypeVar("TBound", bound="Parent") +TConstrained = TypeVar("TConstrained", "Parent", int) +TTypingConstrained = TypeVar("TTypingConstrained", List[int], AbstractSet[str]) +TIntStr = TypeVar("TIntStr", int, str) +TIntCollection = TypeVar("TIntCollection", int, Collection[int]) +TParent = TypeVar("TParent", bound="Parent") +TChild = TypeVar("TChild", bound="Child") + + +class Employee(NamedTuple): + name: str + id: int + + +JSONType = Union[str, float, bool, None, List["JSONType"], Dict[str, "JSONType"]] +myint = NewType("myint", int) +mylist = NewType("mylist", List[int]) + + +class FooGeneric(Generic[T_Foo]): + pass + + +class Parent: + pass + + +class Child(Parent): + def method(self, a: int) -> None: + pass diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ef8731f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +import random +import re +import string +import sys +import typing +from itertools import count +from pathlib import Path + +import pytest +import typing_extensions + +version_re = re.compile(r"_py(\d)(\d)\.py$") +pytest_plugins = ["pytester"] + + +def pytest_ignore_collect( + collection_path: Path, config: pytest.Config +) -> typing.Optional[bool]: + match = version_re.search(collection_path.name) + if match: + version = tuple(int(x) for x in match.groups()) + if sys.version_info < version: + return True + + return None + + +@pytest.fixture +def sample_set() -> set: + # Create a set which, when iterated, returns "bb" as the first item + for num in count(): + letter = random.choice(string.ascii_lowercase) + dummy_set = {letter, num} + if next(iter(dummy_set)) == letter: + return dummy_set + + +@pytest.fixture( + params=[ + pytest.param(typing, id="typing"), + pytest.param(typing_extensions, id="typing_extensions"), + ] +) +def typing_provider(request): + return request.param diff --git a/tests/dummymodule.py b/tests/dummymodule.py new file mode 100644 index 0000000..d53c972 --- /dev/null +++ b/tests/dummymodule.py @@ -0,0 +1,345 @@ +"""Module docstring.""" + +import sys +from contextlib import contextmanager +from typing import ( + TYPE_CHECKING, + Any, + AsyncGenerator, + Callable, + Dict, + Generator, + List, + Literal, + Sequence, + Tuple, + Type, + TypeVar, + Union, + no_type_check, + no_type_check_decorator, + overload, +) + +from typeguard import ( + CollectionCheckStrategy, + ForwardRefPolicy, + typechecked, + typeguard_ignore, +) + +if sys.version_info >= (3, 10): + from typing import ParamSpec +else: + from typing_extensions import ParamSpec + +if TYPE_CHECKING: + from nonexistent import Imaginary + +T = TypeVar("T", bound="DummyClass") +P = ParamSpec("P") + + +if sys.version_info <= (3, 13): + + @no_type_check_decorator + def dummy_decorator(func): + return func + + @dummy_decorator + def non_type_checked_decorated_func(x: int, y: str) -> 6: + # This is to ensure that we avoid using a local variable that's already in use + _call_memo = "foo" # noqa: F841 + return "foo" + + +@typechecked +def type_checked_func(x: int, y: int) -> int: + return x * y + + +@no_type_check +def non_type_checked_func(x: int, y: str) -> 6: + return "foo" + + +@typeguard_ignore +def non_typeguard_checked_func(x: int, y: str) -> 6: + return "foo" + + +class Metaclass(type): + pass + + +@typechecked +class DummyClass(metaclass=Metaclass): + def type_checked_method(self, x: int, y: int) -> int: + return x * y + + @classmethod + def type_checked_classmethod(cls, x: int, y: int) -> int: + return x * y + + @staticmethod + def type_checked_staticmethod(x: int, y: int) -> int: + return x * y + + @classmethod + def undocumented_classmethod(cls, x, y): + pass + + @staticmethod + def undocumented_staticmethod(x, y): + pass + + @property + def unannotated_property(self): + return None + + +def outer(): + @typechecked + class Inner: + def get_self(self) -> "Inner": + return self + + def create_inner() -> "Inner": + return Inner() + + return create_inner + + +@typechecked +class Outer: + class Inner: + pass + + def create_inner(self) -> "Inner": + return Outer.Inner() + + @classmethod + def create_inner_classmethod(cls) -> "Inner": + return Outer.Inner() + + @staticmethod + def create_inner_staticmethod() -> "Inner": + return Outer.Inner() + + +@contextmanager +@typechecked +def dummy_context_manager() -> Generator[int, None, None]: + yield 1 + + +@overload +def overloaded_func(a: int) -> int: ... + + +@overload +def overloaded_func(a: str) -> str: ... + + +@typechecked +def overloaded_func(a: Union[str, int]) -> Union[str, int]: + return a + + +@typechecked +def missing_return() -> int: + pass + + +def get_inner_class() -> type: + @typechecked + class InnerClass: + def get_self(self) -> "InnerClass": + return self + + return InnerClass + + +def create_local_class_instance() -> object: + class Inner: + pass + + @typechecked + def get_instance() -> "Inner": + return instance + + instance = Inner() + return get_instance() + + +@typechecked +async def async_func(a: int) -> str: + return str(a) + + +@typechecked +def generator_func(yield_value: Any, return_value: Any) -> Generator[int, Any, str]: + yield yield_value + return return_value + + +@typechecked +async def asyncgen_func(yield_value: Any) -> AsyncGenerator[int, Any]: + yield yield_value + + +@typechecked +def pep_604_union_args( + x: "Callable[[], Literal[-1]] | Callable[..., Union[int, str]]", +) -> None: + pass + + +@typechecked +def pep_604_union_retval(x: Any) -> "str | int": + return x + + +@typechecked +def builtin_generic_collections(x: "list[set[int]]") -> Any: + return x + + +@typechecked +def paramspec_function(func: P, args: P.args, kwargs: P.kwargs) -> None: + pass + + +@typechecked +def aug_assign() -> int: + x: int = 1 + x += 1 + return x + + +@typechecked +def multi_assign_single_value() -> Tuple[int, float, complex]: + x: int + y: float + z: complex + x = y = z = 6 + return x, y, z + + +@typechecked +def multi_assign_iterable() -> Tuple[Sequence[int], Sequence[float], Sequence[complex]]: + x: Sequence[int] + y: Sequence[float] + z: Sequence[complex] + x = y = z = [6, 7] + return x, y, z + + +@typechecked +def unpacking_assign() -> Tuple[int, str]: + x: int + x, y = (1, "foo") + return x, y + + +@typechecked +def unpacking_assign_generator() -> Tuple[int, str]: + def genfunc(): + yield 1 + yield "foo" + + x: int + x, y = genfunc() + return x, y + + +@typechecked +def unpacking_assign_star_with_annotation() -> Tuple[int, List[bytes], str]: + x: int + z: str + x, *y, z = (1, b"abc", b"bah", "foo") + return x, y, z + + +@typechecked +def unpacking_assign_star_no_annotation(value: Any) -> Tuple[int, List[bytes], str]: + x: int + y: List[bytes] + z: str + x, *y, z = value + return x, y, z + + +@typechecked(forward_ref_policy=ForwardRefPolicy.ERROR) +def override_forward_ref_policy(value: "NonexistentType") -> None: # noqa: F821 + pass + + +@typechecked(typecheck_fail_callback=lambda exc, memo: print(exc)) +def override_typecheck_fail_callback(value: int) -> None: + pass + + +@typechecked(collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS) +def override_collection_check_strategy(value: List[int]) -> None: + pass + + +@typechecked(typecheck_fail_callback=lambda exc, memo: print(exc)) +class OverrideClass: + def override_typecheck_fail_callback(self, value: int) -> None: + pass + + class Inner: + @typechecked + def override_typecheck_fail_callback(self, value: int) -> None: + pass + + +@typechecked +def typed_variable_args( + *args: str, **kwargs: int +) -> Tuple[Tuple[str, ...], Dict[str, int]]: + return args, kwargs + + +@typechecked +def guarded_type_hint_plain(x: "Imaginary") -> "Imaginary": + y: Imaginary = x + return y + + +@typechecked +def guarded_type_hint_subscript_toplevel(x: "Imaginary[int]") -> "Imaginary[int]": + y: Imaginary[int] = x + return y + + +@typechecked +def guarded_type_hint_subscript_nested( + x: List["Imaginary[int]"], +) -> List["Imaginary[int]"]: + y: List[Imaginary[int]] = x + return y + + +@typechecked +def literal(x: Literal["foo"]) -> Literal["foo"]: + y: Literal["foo"] = x + return y + + +@typechecked +def literal_in_union(x: Union[Literal["foo"],]) -> Literal["foo"]: + y: Literal["foo"] = x + return y + + +@typechecked +def typevar_forwardref(x: Type[T]) -> T: + return x() + + +def never_called(x: List["NonExistentType"]) -> List["NonExistentType"]: # noqa: F821 + """Regression test for #335.""" + return x diff --git a/tests/mypy/negative.py b/tests/mypy/negative.py new file mode 100644 index 0000000..ec93022 --- /dev/null +++ b/tests/mypy/negative.py @@ -0,0 +1,56 @@ +from typeguard import typechecked, typeguard_ignore + + +@typechecked +def foo(x: int) -> int: + return x + 1 + + +@typechecked +def bar(x: int) -> int: + return str(x) # noqa: E501 # error: Incompatible return value type (got "str", expected "int") [return-value] + + +@typeguard_ignore +def non_typeguard_checked_func(x: int) -> int: + return str(x) # noqa: E501 # error: Incompatible return value type (got "str", expected "int") [return-value] + + +@typechecked +def returns_str() -> str: + return bar(0) # noqa: E501 # error: Incompatible return value type (got "int", expected "str") [return-value] + + +@typechecked +def arg_type(x: int) -> str: + return True # noqa: E501 # error: Incompatible return value type (got "bool", expected "str") [return-value] + + +@typechecked +def ret_type() -> str: + return True # noqa: E501 # error: Incompatible return value type (got "bool", expected "str") [return-value] + + +_ = arg_type(foo) # noqa: E501 # error: Argument 1 to "arg_type" has incompatible type "Callable[[int], int]"; expected "int" [arg-type] +_ = foo("typeguard") # noqa: E501 # error: Argument 1 to "foo" has incompatible type "str"; expected "int" [arg-type] + + +@typechecked +class MyClass: + def __init__(self, x: int = 0) -> None: + self.x = x + + def add(self, y: int) -> int: + return self.x + y + + +def get_value(c: MyClass) -> int: + return c.x + + +def create_myclass(x: int) -> MyClass: + return MyClass(x) + + +_ = get_value("foo") # noqa: E501 # error: Argument 1 to "get_value" has incompatible type "str"; expected "MyClass" [arg-type] +_ = MyClass(returns_str()) # noqa: E501 # error: Argument 1 to "MyClass" has incompatible type "str"; expected "int" [arg-type] diff --git a/tests/mypy/positive.py b/tests/mypy/positive.py new file mode 100644 index 0000000..dc8a350 --- /dev/null +++ b/tests/mypy/positive.py @@ -0,0 +1,55 @@ +from typing import Callable + +from typeguard import typechecked + + +@typechecked +def foo(x: str) -> str: + return "hello " + x + + +def takes_callable(f: Callable[[str], str]) -> str: + return f("typeguard") + + +takes_callable(foo) + + +@typechecked +def has_valid_arguments(x: int, y: str) -> None: + pass + + +def has_valid_return_type(y: str) -> str: + return y + + +@typechecked +class MyClass: + def __init__(self, x: int) -> None: + self.x = x + + def add(self, y: int) -> int: + return self.x + y + + +def get_value(c: MyClass) -> int: + return c.x + + +@typechecked +def get_value_checked(c: MyClass) -> int: + return c.x + + +def create_myclass(x: int) -> MyClass: + return MyClass(x) + + +@typechecked +def create_myclass_checked(x: int) -> MyClass: + return MyClass(x) + + +get_value(create_myclass(3)) +get_value_checked(create_myclass_checked(1)) diff --git a/tests/mypy/test_type_annotations.py b/tests/mypy/test_type_annotations.py new file mode 100644 index 0000000..aacee7f --- /dev/null +++ b/tests/mypy/test_type_annotations.py @@ -0,0 +1,113 @@ +import os +import platform +import re +import subprocess +from typing import Dict, List + +import pytest + +POSITIVE_FILE = "positive.py" +NEGATIVE_FILE = "negative.py" +LINE_PATTERN = NEGATIVE_FILE + ":([0-9]+):" + +pytestmark = [ + pytest.mark.skipif( + platform.python_implementation() == "PyPy", + reason="MyPy does not work with PyPy yet", + ) +] + + +def get_mypy_cmd(filename: str) -> List[str]: + return ["mypy", "--strict", filename] + + +def get_negative_mypy_output() -> str: + """ + Get the output from running mypy on the negative examples file. + """ + process = subprocess.run( + get_mypy_cmd(NEGATIVE_FILE), stdout=subprocess.PIPE, check=False + ) + output = process.stdout.decode() + assert output + return output + + +def get_expected_errors() -> Dict[int, str]: + """ + Extract the expected errors from comments in the negative examples file. + """ + with open(NEGATIVE_FILE) as f: + lines = f.readlines() + + expected = {} + + for idx, line in enumerate(lines): + line = line.rstrip() + if "# error" in line: + expected[idx + 1] = line[line.index("# error") + 2 :] + + # Sanity check. Should update if negative.py changes. + assert len(expected) == 9 + return expected + + +def get_mypy_errors() -> Dict[int, str]: + """ + Extract the errors from running mypy on the negative examples file. + """ + mypy_output = get_negative_mypy_output() + + got = {} + for line in mypy_output.splitlines(): + m = re.match(LINE_PATTERN, line) + if m is None: + continue + got[int(m.group(1))] = line[len(m.group(0)) + 1 :] + + return got + + +@pytest.fixture +def chdir_local() -> None: + """ + Change to the local directory. This is so that mypy treats imports from + typeguard as external imports instead of source code (which is handled + differently by mypy). + """ + os.chdir(os.path.dirname(__file__)) + + +@pytest.mark.usefixtures("chdir_local") +def test_positive() -> None: + """ + Run mypy on the positive test file. There should be no errors. + """ + subprocess.check_call(get_mypy_cmd(POSITIVE_FILE)) + + +@pytest.mark.usefixtures("chdir_local") +def test_negative() -> None: + """ + Run mypy on the negative test file. This should fail. The errors from mypy + should match the comments in the file. + """ + got_errors = get_mypy_errors() + expected_errors = get_expected_errors() + + if set(got_errors) != set(expected_errors): + raise RuntimeError( + f"Expected error lines {set(expected_errors)} does not " + + f"match mypy error lines {set(got_errors)}." + ) + + mismatches = [ + (idx, expected_errors[idx], got_errors[idx]) + for idx in expected_errors + if expected_errors[idx] != got_errors[idx] + ] + for idx, expected, got in mismatches: + print(f"Line {idx}", f"Expected: {expected}", f"Got: {got}", sep="\n\t") + if mismatches: + raise RuntimeError("Error messages changed") diff --git a/tests/test_checkers.py b/tests/test_checkers.py new file mode 100644 index 0000000..1ba0407 --- /dev/null +++ b/tests/test_checkers.py @@ -0,0 +1,1505 @@ +import collections.abc +import sys +import types +from contextlib import nullcontext +from functools import partial +from io import BytesIO, StringIO +from pathlib import Path +from typing import ( + IO, + AbstractSet, + Annotated, + Any, + AnyStr, + BinaryIO, + Callable, + Collection, + ContextManager, + Dict, + ForwardRef, + FrozenSet, + Iterable, + Iterator, + List, + Literal, + Mapping, + MutableMapping, + Optional, + Protocol, + Sequence, + Set, + Sized, + TextIO, + Tuple, + Type, + TypeVar, + Union, +) + +import pytest +from typing_extensions import LiteralString + +from typeguard import ( + CollectionCheckStrategy, + ForwardRefPolicy, + TypeCheckError, + TypeCheckMemo, + TypeHintWarning, + check_type, + check_type_internal, + suppress_type_checks, +) +from typeguard._checkers import is_typeddict +from typeguard._utils import qualified_name + +from . import ( + Child, + Employee, + JSONType, + Parent, + TChild, + TIntStr, + TParent, + TTypingConstrained, + myint, + mylist, +) + +if sys.version_info >= (3, 11): + SubclassableAny = Any +else: + from typing_extensions import Any as SubclassableAny + +if sys.version_info >= (3, 10): + from typing import Concatenate, ParamSpec, TypeGuard +else: + from typing_extensions import Concatenate, ParamSpec, TypeGuard + +P = ParamSpec("P") + + +@pytest.mark.skipif( + sys.version_info >= (3, 13), reason="AnyStr is deprecated on Python 3.13" +) +class TestAnyStr: + @pytest.mark.parametrize( + "value", [pytest.param("bar", id="str"), pytest.param(b"bar", id="bytes")] + ) + def test_valid(self, value): + check_type(value, AnyStr) + + def test_bad_type(self): + pytest.raises(TypeCheckError, check_type, 4, AnyStr).match( + r"does not match any of the constraints \(bytes, str\)" + ) + + +class TestBytesLike: + @pytest.mark.parametrize( + "value", + [ + pytest.param(b"test", id="bytes"), + pytest.param(bytearray(b"test"), id="bytearray"), + pytest.param(memoryview(b"test"), id="memoryview"), + ], + ) + def test_valid(self, value): + check_type(value, bytes) + + def test_fail(self): + pytest.raises(TypeCheckError, check_type, "test", bytes).match( + r"str is not bytes-like" + ) + + +class TestFloat: + @pytest.mark.parametrize( + "value", [pytest.param(3, id="int"), pytest.param(3.87, id="float")] + ) + def test_valid(self, value): + check_type(value, float) + + def test_bad_type(self): + pytest.raises(TypeCheckError, check_type, "foo", float).match( + r"str is neither float or int" + ) + + +class TestComplexNumber: + @pytest.mark.parametrize( + "value", + [ + pytest.param(3, id="int"), + pytest.param(3.87, id="float"), + pytest.param(3.87 + 8j, id="complex"), + ], + ) + def test_valid(self, value): + check_type(value, complex) + + def test_bad_type(self): + pytest.raises(TypeCheckError, check_type, "foo", complex).match( + "str is neither complex, float or int" + ) + + +class TestCallable: + def test_any_args(self): + def some_callable(x: int, y: str) -> int: + pass + + check_type(some_callable, Callable[..., int]) + + def test_exact_arg_count(self): + def some_callable(x: int, y: str) -> int: + pass + + check_type(some_callable, Callable[[int, str], int]) + + def test_bad_type(self): + pytest.raises(TypeCheckError, check_type, 5, Callable[..., int]).match( + "is not callable" + ) + + def test_too_few_arguments(self): + def some_callable(x: int) -> int: + pass + + pytest.raises( + TypeCheckError, check_type, some_callable, Callable[[int, str], int] + ).match( + r"has too few arguments in its declaration; expected 2 but 1 argument\(s\) " + r"declared" + ) + + def test_too_many_arguments(self): + def some_callable(x: int, y: str, z: float) -> int: + pass + + pytest.raises( + TypeCheckError, check_type, some_callable, Callable[[int, str], int] + ).match( + r"has too many mandatory positional arguments in its declaration; expected " + r"2 but 3 mandatory positional argument\(s\) declared" + ) + + def test_mandatory_kwonlyargs(self): + def some_callable(x: int, y: str, *, z: float, bar: str) -> int: + pass + + pytest.raises( + TypeCheckError, check_type, some_callable, Callable[[int, str], int] + ).match(r"has mandatory keyword-only arguments in its declaration: z, bar") + + def test_class(self): + """ + Test that passing a class as a callable does not count the "self" argument + against the ones declared in the Callable specification. + + """ + + class SomeClass: + def __init__(self, x: int, y: str): + pass + + check_type(SomeClass, Callable[[int, str], Any]) + + def test_plain(self): + def callback(a): + pass + + check_type(callback, Callable) + + def test_partial_class(self): + """ + Test that passing a bound method as a callable does not count the "self" + argument against the ones declared in the Callable specification. + + """ + + class SomeClass: + def __init__(self, x: int, y: str): + pass + + check_type(partial(SomeClass, y="foo"), Callable[[int], Any]) + + def test_bound_method(self): + """ + Test that passing a bound method as a callable does not count the "self" + argument against the ones declared in the Callable specification. + + """ + check_type(Child().method, Callable[[int], Any]) + + def test_partial_bound_method(self): + """ + Test that passing a bound method as a callable does not count the "self" + argument against the ones declared in the Callable specification. + + """ + check_type(partial(Child().method, 1), Callable[[], Any]) + + def test_defaults(self): + """ + Test that a callable having "too many" arguments don't raise an error if the + extra arguments have default values. + + """ + + def some_callable(x: int, y: str, z: float = 1.2) -> int: + pass + + check_type(some_callable, Callable[[int, str], Any]) + + def test_builtin(self): + """ + Test that checking a Callable annotation against a builtin callable does not + raise an error. + + """ + check_type([].append, Callable[[int], Any]) + + def test_concatenate(self): + """Test that ``Concatenate`` in the arglist is ignored.""" + check_type([].append, Callable[Concatenate[object, P], Any]) + + def test_positional_only_arg_with_default(self): + def some_callable(x: int = 1, /) -> None: + pass + + check_type(some_callable, Callable[[int], Any]) + + +class TestLiteral: + def test_literal_union(self): + annotation = Union[str, Literal[1, 6, 8]] + check_type(6, annotation) + pytest.raises(TypeCheckError, check_type, 4, annotation).match( + r"int did not match any element in the union:\n" + r" str: is not an instance of str\n" + r" Literal\[1, 6, 8\]: is not any of \(1, 6, 8\)$" + ) + + def test_literal_nested(self): + annotation = Literal[1, Literal["x", "a", Literal["z"]], 6, 8] + check_type("z", annotation) + pytest.raises(TypeCheckError, check_type, 4, annotation).match( + r"int is not any of \(1, 'x', 'a', 'z', 6, 8\)$" + ) + + def test_literal_int_as_bool(self): + pytest.raises(TypeCheckError, check_type, 0, Literal[False]) + pytest.raises(TypeCheckError, check_type, 1, Literal[True]) + + def test_literal_illegal_value(self): + pytest.raises(TypeError, check_type, 4, Literal[1, 1.1]).match( + r"Illegal literal value: 1.1$" + ) + + +class TestMapping: + class DummyMapping(collections.abc.Mapping): + _values = {"a": 1, "b": 10, "c": 100} + + def __getitem__(self, index: str): + return self._values[index] + + def __iter__(self): + return iter(self._values) + + def __len__(self) -> int: + return len(self._values) + + def test_bad_type(self): + pytest.raises(TypeCheckError, check_type, 5, Mapping[str, int]).match( + "is not a mapping" + ) + + def test_bad_key_type(self): + pytest.raises( + TypeCheckError, check_type, TestMapping.DummyMapping(), Mapping[int, int] + ).match( + f"key 'a' of {__name__}.TestMapping.DummyMapping is not an instance of int" + ) + + def test_bad_value_type(self): + pytest.raises( + TypeCheckError, check_type, TestMapping.DummyMapping(), Mapping[str, str] + ).match( + f"value of key 'a' of {__name__}.TestMapping.DummyMapping is not an " + f"instance of str" + ) + + def test_bad_key_type_full_check(self): + pytest.raises( + TypeCheckError, + check_type, + {"x": 1, 3: 2}, + Mapping[str, int], + collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, + ).match("key 3 of dict is not an instance of str") + + def test_bad_value_type_full_check(self): + pytest.raises( + TypeCheckError, + check_type, + {"x": 1, "y": "a"}, + Mapping[str, int], + collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, + ).match("value of key 'y' of dict is not an instance of int") + + def test_any_value_type(self): + check_type(TestMapping.DummyMapping(), Mapping[str, Any]) + + +class TestMutableMapping: + class DummyMutableMapping(collections.abc.MutableMapping): + _values = {"a": 1, "b": 10, "c": 100} + + def __getitem__(self, index: str): + return self._values[index] + + def __setitem__(self, key, value): + self._values[key] = value + + def __delitem__(self, key): + del self._values[key] + + def __iter__(self): + return iter(self._values) + + def __len__(self) -> int: + return len(self._values) + + def test_bad_type(self): + pytest.raises(TypeCheckError, check_type, 5, MutableMapping[str, int]).match( + "is not a mutable mapping" + ) + + def test_bad_key_type(self): + pytest.raises( + TypeCheckError, + check_type, + TestMutableMapping.DummyMutableMapping(), + MutableMapping[int, int], + ).match( + f"key 'a' of {__name__}.TestMutableMapping.DummyMutableMapping is not an " + f"instance of int" + ) + + def test_bad_value_type(self): + pytest.raises( + TypeCheckError, + check_type, + TestMutableMapping.DummyMutableMapping(), + MutableMapping[str, str], + ).match( + f"value of key 'a' of {__name__}.TestMutableMapping.DummyMutableMapping " + f"is not an instance of str" + ) + + +class TestDict: + def test_bad_type(self): + pytest.raises(TypeCheckError, check_type, 5, Dict[str, int]).match( + "int is not a dict" + ) + + def test_bad_key_type(self): + pytest.raises(TypeCheckError, check_type, {1: 2}, Dict[str, int]).match( + "key 1 of dict is not an instance of str" + ) + + def test_bad_value_type(self): + pytest.raises(TypeCheckError, check_type, {"x": "a"}, Dict[str, int]).match( + "value of key 'x' of dict is not an instance of int" + ) + + def test_bad_key_type_full_check(self): + pytest.raises( + TypeCheckError, + check_type, + {"x": 1, 3: 2}, + Dict[str, int], + collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, + ).match("key 3 of dict is not an instance of str") + + def test_bad_value_type_full_check(self): + pytest.raises( + TypeCheckError, + check_type, + {"x": 1, "y": "a"}, + Dict[str, int], + collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, + ).match("value of key 'y' of dict is not an instance of int") + + def test_custom_dict_generator_items(self): + class CustomDict(dict): + def items(self): + for key in self: + yield key, self[key] + + check_type(CustomDict(a=1), Dict[str, int]) + + +class TestTypedDict: + @pytest.mark.parametrize( + "value, total, error_re", + [ + pytest.param({"x": 6, "y": "foo"}, True, None, id="correct"), + pytest.param( + {"y": "foo"}, + True, + r'dict is missing required key\(s\): "x"', + id="missing_x", + ), + pytest.param( + {"x": 6, "y": 3}, True, "dict is not an instance of str", id="wrong_y" + ), + pytest.param( + {"x": 6}, + True, + r'is missing required key\(s\): "y"', + id="missing_y_error", + ), + pytest.param({"x": 6}, False, None, id="missing_y_ok"), + pytest.param( + {"x": "abc"}, False, "dict is not an instance of int", id="wrong_x" + ), + pytest.param( + {"x": 6, "foo": "abc"}, + False, + r'dict has unexpected extra key\(s\): "foo"', + id="unknown_key", + ), + pytest.param( + None, + True, + "is not a dict", + id="not_dict", + ), + ], + ) + def test_typed_dict( + self, value, total: bool, error_re: Optional[str], typing_provider + ): + class DummyDict(typing_provider.TypedDict, total=total): + x: int + y: str + + if error_re: + pytest.raises(TypeCheckError, check_type, value, DummyDict).match(error_re) + else: + check_type(value, DummyDict) + + def test_inconsistent_keys_invalid(self, typing_provider): + class DummyDict(typing_provider.TypedDict): + x: int + + pytest.raises( + TypeCheckError, check_type, {"x": 1, "y": 2, b"z": 3}, DummyDict + ).match(r'dict has unexpected extra key\(s\): "y", "b\'z\'"') + + def test_notrequired_pass(self, typing_provider): + try: + NotRequired = typing_provider.NotRequired + except AttributeError: + pytest.skip(f"'NotRequired' not found in {typing_provider.__name__!r}") + + class DummyDict(typing_provider.TypedDict): + x: int + y: NotRequired[int] + z: "NotRequired[int]" + + check_type({"x": 8}, DummyDict) + + def test_notrequired_fail(self, typing_provider): + try: + NotRequired = typing_provider.NotRequired + except AttributeError: + pytest.skip(f"'NotRequired' not found in {typing_provider.__name__!r}") + + class DummyDict(typing_provider.TypedDict): + x: int + y: NotRequired[int] + z: "NotRequired[int]" + + with pytest.raises( + TypeCheckError, match=r"value of key 'y' of dict is not an instance of int" + ): + check_type({"x": 1, "y": "foo"}, DummyDict) + + with pytest.raises( + TypeCheckError, match=r"value of key 'z' of dict is not an instance of int" + ): + check_type({"x": 1, "y": 6, "z": "foo"}, DummyDict) + + def test_is_typeddict(self, typing_provider): + # Ensure both typing.TypedDict and typing_extensions.TypedDict are recognized + class DummyDict(typing_provider.TypedDict): + x: int + + assert is_typeddict(DummyDict) + assert not is_typeddict(dict) + + +class TestList: + def test_bad_type(self): + pytest.raises(TypeCheckError, check_type, 5, List[int]).match( + "int is not a list" + ) + + def test_first_check_success(self): + check_type(["aa", "bb", 1], List[str]) + + def test_first_check_empty(self): + check_type([], List[str]) + + def test_first_check_fail(self): + pytest.raises(TypeCheckError, check_type, ["bb"], List[int]).match( + "list is not an instance of int" + ) + + def test_full_check_fail(self): + pytest.raises( + TypeCheckError, + check_type, + [1, 2, "bb"], + List[int], + collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, + ).match("list is not an instance of int") + + +class TestSequence: + def test_bad_type(self): + pytest.raises(TypeCheckError, check_type, 5, Sequence[int]).match( + "int is not a sequence" + ) + + @pytest.mark.parametrize( + "value", + [pytest.param([1, "bb"], id="list"), pytest.param((1, "bb"), id="tuple")], + ) + def test_first_check_success(self, value): + check_type(value, Sequence[int]) + + def test_first_check_empty(self): + check_type([], Sequence[int]) + + def test_first_check_fail(self): + pytest.raises(TypeCheckError, check_type, ["bb"], Sequence[int]).match( + "list is not an instance of int" + ) + + def test_full_check_fail(self): + pytest.raises( + TypeCheckError, + check_type, + [1, 2, "bb"], + Sequence[int], + collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, + ).match("list is not an instance of int") + + +class TestAbstractSet: + def test_custom_type(self): + class DummySet(AbstractSet[int]): + def __contains__(self, x: object) -> bool: + return x == 1 + + def __len__(self) -> int: + return 1 + + def __iter__(self) -> Iterator[int]: + yield 1 + + check_type(DummySet(), AbstractSet[int]) + + def test_bad_type(self): + pytest.raises(TypeCheckError, check_type, 5, AbstractSet[int]).match( + "int is not a set" + ) + + def test_first_check_fail(self, sample_set): + # Create a set which, when iterated, returns "bb" as the first item + pytest.raises(TypeCheckError, check_type, sample_set, AbstractSet[int]).match( + "set is not an instance of int" + ) + + def test_full_check_fail(self): + pytest.raises( + TypeCheckError, + check_type, + {1, 2, "bb"}, + AbstractSet[int], + collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, + ).match("set is not an instance of int") + + +class TestSet: + def test_bad_type(self): + pytest.raises(TypeCheckError, check_type, 5, Set[int]).match("int is not a set") + + def test_valid(self): + check_type({1, 2}, Set[int]) + + def test_first_check_empty(self): + check_type(set(), Set[int]) + + def test_first_check_fail(self, sample_set: set): + pytest.raises(TypeCheckError, check_type, sample_set, Set[int]).match( + "set is not an instance of int" + ) + + def test_full_check_fail(self): + pytest.raises( + TypeCheckError, + check_type, + {1, 2, "bb"}, + Set[int], + collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, + ).match("set is not an instance of int") + + +class TestFrozenSet: + def test_bad_type(self): + pytest.raises(TypeCheckError, check_type, 5, FrozenSet[int]).match( + "int is not a frozenset" + ) + + def test_valid(self): + check_type(frozenset({1, 2}), FrozenSet[int]) + + def test_first_check_empty(self): + check_type(frozenset(), FrozenSet[int]) + + def test_first_check_fail(self, sample_set: set): + pytest.raises( + TypeCheckError, check_type, frozenset(sample_set), FrozenSet[int] + ).match("set is not an instance of int") + + def test_full_check_fail(self): + pytest.raises( + TypeCheckError, + check_type, + frozenset({1, 2, "bb"}), + FrozenSet[int], + collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, + ).match("set is not an instance of int") + + def test_set_against_frozenset(self, sample_set: set): + pytest.raises(TypeCheckError, check_type, sample_set, FrozenSet[int]).match( + "set is not a frozenset" + ) + + +@pytest.mark.parametrize( + "annotated_type", + [ + pytest.param(Tuple, id="typing"), + pytest.param( + tuple, + id="builtin", + marks=[ + pytest.mark.skipif( + sys.version_info < (3, 9), + reason="builtins.tuple is not parametrizable before Python 3.9", + ) + ], + ), + ], +) +class TestTuple: + def test_bad_type(self, annotated_type: Any): + pytest.raises(TypeCheckError, check_type, 5, annotated_type[int]).match( + "int is not a tuple" + ) + + def test_first_check_empty(self, annotated_type: Any): + check_type((), annotated_type[int, ...]) + + def test_unparametrized_tuple(self, annotated_type: Any): + check_type((5, "foo"), annotated_type) + + def test_unparametrized_tuple_fail(self, annotated_type: Any): + pytest.raises(TypeCheckError, check_type, 5, annotated_type).match( + "int is not a tuple" + ) + + def test_too_many_elements(self, annotated_type: Any): + pytest.raises( + TypeCheckError, check_type, (1, "aa", 2), annotated_type[int, str] + ).match(r"tuple has wrong number of elements \(expected 2, got 3 instead\)") + + def test_too_few_elements(self, annotated_type: Any): + pytest.raises(TypeCheckError, check_type, (1,), annotated_type[int, str]).match( + r"tuple has wrong number of elements \(expected 2, got 1 instead\)" + ) + + def test_bad_element(self, annotated_type: Any): + pytest.raises( + TypeCheckError, check_type, (1, 2), annotated_type[int, str] + ).match("tuple is not an instance of str") + + def test_ellipsis_bad_element(self, annotated_type: Any): + pytest.raises( + TypeCheckError, check_type, ("blah",), annotated_type[int, ...] + ).match("tuple is not an instance of int") + + def test_ellipsis_bad_element_full_check(self, annotated_type: Any): + pytest.raises( + TypeCheckError, + check_type, + (1, 2, "blah"), + annotated_type[int, ...], + collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS, + ).match("tuple is not an instance of int") + + def test_empty_tuple(self, annotated_type: Any): + check_type((), annotated_type[()]) + + def test_empty_tuple_fail(self, annotated_type: Any): + pytest.raises(TypeCheckError, check_type, (1,), annotated_type[()]).match( + "tuple is not an empty tuple" + ) + + +class TestNamedTuple: + def test_valid(self): + check_type(Employee("bob", 1), Employee) + + def test_type_mismatch(self): + pytest.raises(TypeCheckError, check_type, ("bob", 1), Employee).match( + r"tuple is not a named tuple of type tests.Employee" + ) + + def test_wrong_field_type(self): + pytest.raises(TypeCheckError, check_type, Employee(2, 1), Employee).match( + r"Employee is not an instance of str" + ) + + +class TestUnion: + @pytest.mark.parametrize( + "value", [pytest.param(6, id="int"), pytest.param("aa", id="str")] + ) + def test_valid(self, value): + check_type(value, Union[str, int]) + + def test_typing_type_fail(self): + pytest.raises(TypeCheckError, check_type, 1, Union[str, Collection]).match( + "int did not match any element in the union:\n" + " str: is not an instance of str\n" + " Collection: is not an instance of collections.abc.Collection" + ) + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(Union[str, int], id="pep484"), + pytest.param( + ForwardRef("str | int"), + id="pep604", + marks=[ + pytest.mark.skipif( + sys.version_info < (3, 10), reason="Requires Python 3.10+" + ) + ], + ), + ], + ) + @pytest.mark.parametrize( + "value", [pytest.param(6.5, id="float"), pytest.param(b"aa", id="bytes")] + ) + def test_union_fail(self, annotation, value): + qualname = qualified_name(value) + pytest.raises(TypeCheckError, check_type, value, annotation).match( + f"{qualname} did not match any element in the union:\n" + f" str: is not an instance of str\n" + f" int: is not an instance of int" + ) + + @pytest.mark.skipif( + sys.implementation.name != "cpython", + reason="Test relies on CPython's reference counting behavior", + ) + def test_union_reference_leak(self): + class Leak: + def __del__(self): + nonlocal leaked + leaked = False + + def inner1(): + leak = Leak() # noqa: F841 + check_type(b"asdf", Union[str, bytes]) + + leaked = True + inner1() + assert not leaked + + def inner2(): + leak = Leak() # noqa: F841 + check_type(b"asdf", Union[bytes, str]) + + leaked = True + inner2() + assert not leaked + + def inner3(): + leak = Leak() # noqa: F841 + with pytest.raises(TypeCheckError, match="any element in the union:"): + check_type(1, Union[str, bytes]) + + leaked = True + inner3() + assert not leaked + + @pytest.mark.skipif( + sys.implementation.name != "cpython", + reason="Test relies on CPython's reference counting behavior", + ) + @pytest.mark.skipif(sys.version_info < (3, 10), reason="UnionType requires 3.10") + def test_uniontype_reference_leak(self): + class Leak: + def __del__(self): + nonlocal leaked + leaked = False + + def inner1(): + leak = Leak() # noqa: F841 + check_type(b"asdf", str | bytes) + + leaked = True + inner1() + assert not leaked + + def inner2(): + leak = Leak() # noqa: F841 + check_type(b"asdf", bytes | str) + + leaked = True + inner2() + assert not leaked + + def inner3(): + leak = Leak() # noqa: F841 + with pytest.raises(TypeCheckError, match="any element in the union:"): + check_type(1, Union[str, bytes]) + + leaked = True + inner3() + assert not leaked + + @pytest.mark.skipif(sys.version_info < (3, 10), reason="UnionType requires 3.10") + def test_raw_uniontype_success(self): + check_type(str | int, types.UnionType) + + @pytest.mark.skipif(sys.version_info < (3, 10), reason="UnionType requires 3.10") + def test_raw_uniontype_fail(self): + with pytest.raises( + TypeCheckError, match=r"class str is not an instance of \w+\.UnionType$" + ): + check_type(str, types.UnionType) + + +class TestTypevar: + def test_bound(self): + check_type(Child(), TParent) + + def test_bound_fail(self): + with pytest.raises(TypeCheckError, match="is not an instance of tests.Child"): + check_type(Parent(), TChild) + + @pytest.mark.parametrize( + "value", [pytest.param([6, 7], id="int"), pytest.param({"aa", "bb"}, id="str")] + ) + def test_collection_constraints(self, value): + check_type(value, TTypingConstrained) + + def test_collection_constraints_fail(self): + pytest.raises(TypeCheckError, check_type, {1, 2}, TTypingConstrained).match( + r"set does not match any of the constraints \(List\[int\], " + r"AbstractSet\[str\]\)" + ) + + def test_constraints_fail(self): + pytest.raises(TypeCheckError, check_type, 2.5, TIntStr).match( + r"float does not match any of the constraints \(int, str\)" + ) + + +class TestNewType: + def test_simple_valid(self): + check_type(1, myint) + + def test_simple_bad_value(self): + pytest.raises(TypeCheckError, check_type, "a", myint).match( + r"str is not an instance of int" + ) + + def test_generic_valid(self): + check_type([1], mylist) + + def test_generic_bad_value(self): + pytest.raises(TypeCheckError, check_type, ["a"], mylist).match( + r"item 0 of list is not an instance of int" + ) + + +class TestType: + @pytest.mark.parametrize("annotation", [pytest.param(Type), pytest.param(type)]) + def test_unparametrized(self, annotation: Any): + check_type(TestNewType, annotation) + + @pytest.mark.parametrize("annotation", [pytest.param(Type), pytest.param(type)]) + def test_unparametrized_fail(self, annotation: Any): + pytest.raises(TypeCheckError, check_type, 1, annotation).match( + "int is not a class" + ) + + @pytest.mark.parametrize( + "value", [pytest.param(Parent, id="exact"), pytest.param(Child, id="subclass")] + ) + def test_parametrized(self, value): + check_type(value, Type[Parent]) + + def test_parametrized_fail(self): + pytest.raises(TypeCheckError, check_type, int, Type[str]).match( + "class int is not a subclass of str" + ) + + @pytest.mark.parametrize( + "value", [pytest.param(str, id="str"), pytest.param(int, id="int")] + ) + def test_union(self, value): + check_type(value, Type[Union[str, int, list]]) + + def test_union_any(self): + check_type(list, Type[Union[str, int, Any]]) + + def test_any(self): + check_type(list, Type[Any]) + + def test_union_fail(self): + pytest.raises( + TypeCheckError, check_type, dict, Type[Union[str, int, list]] + ).match( + "class dict did not match any element in the union:\n" + " str: is not a subclass of str\n" + " int: is not a subclass of int\n" + " list: is not a subclass of list" + ) + + def test_union_typevar(self): + T = TypeVar("T", bound=Parent) + check_type(Child, Type[T]) + + @pytest.mark.parametrize("check_against", [type, Type[Any]]) + def test_generic_aliase(self, check_against): + check_type(dict[str, str], check_against) + check_type(Dict, check_against) + check_type(Dict[str, str], check_against) + + +class TestIO: + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(BinaryIO, id="direct"), + pytest.param(IO[bytes], id="parametrized"), + ], + ) + def test_binary_valid(self, annotation): + check_type(BytesIO(), annotation) + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(BinaryIO, id="direct"), + pytest.param(IO[bytes], id="parametrized"), + ], + ) + def test_binary_fail(self, annotation): + pytest.raises(TypeCheckError, check_type, StringIO(), annotation).match( + "_io.StringIO is not a binary I/O object" + ) + + def test_binary_real_file(self, tmp_path: Path): + with tmp_path.joinpath("testfile").open("wb") as f: + check_type(f, BinaryIO) + + @pytest.mark.parametrize( + "annotation", + [pytest.param(TextIO, id="direct"), pytest.param(IO[str], id="parametrized")], + ) + def test_text_valid(self, annotation): + check_type(StringIO(), annotation) + + @pytest.mark.parametrize( + "annotation", + [pytest.param(TextIO, id="direct"), pytest.param(IO[str], id="parametrized")], + ) + def test_text_fail(self, annotation): + pytest.raises(TypeCheckError, check_type, BytesIO(), annotation).match( + "_io.BytesIO is not a text based I/O object" + ) + + def test_text_real_file(self, tmp_path: Path): + with tmp_path.joinpath("testfile").open("w") as f: + check_type(f, TextIO) + + +class TestIntersectingProtocol: + SIT = TypeVar("SIT", covariant=True) + + class SizedIterable( + Sized, + Iterable[SIT], + Protocol[SIT], + ): ... + + @pytest.mark.parametrize( + "subject, predicate_type", + ( + pytest.param( + (), + SizedIterable, + id="empty_tuple_unspecialized", + ), + pytest.param( + range(2), + SizedIterable, + id="range", + ), + pytest.param( + (), + SizedIterable[int], + id="empty_tuple_int_specialized", + ), + pytest.param( + (1, 2, 3), + SizedIterable[int], + id="tuple_int_specialized", + ), + pytest.param( + ("1", "2", "3"), + SizedIterable[str], + id="tuple_str_specialized", + ), + ), + ) + def test_valid_member_passes(self, subject: object, predicate_type: type) -> None: + for _ in range(2): # Makes sure that the cache is also exercised + check_type(subject, predicate_type) + + xfail_nested_protocol_checks = pytest.mark.xfail( + reason="false negative due to missing support for nested protocol checks", + ) + + @pytest.mark.parametrize( + "subject, predicate_type", + ( + pytest.param( + (1 for _ in ()), + SizedIterable, + id="generator", + ), + pytest.param( + range(2), + SizedIterable[str], + marks=xfail_nested_protocol_checks, + id="range_str_specialized", + ), + pytest.param( + (1, 2, 3), + SizedIterable[str], + marks=xfail_nested_protocol_checks, + id="int_tuple_str_specialized", + ), + pytest.param( + ("1", "2", "3"), + SizedIterable[int], + marks=xfail_nested_protocol_checks, + id="str_tuple_int_specialized", + ), + ), + ) + def test_raises_for_non_member(self, subject: object, predicate_type: type) -> None: + with pytest.raises(TypeCheckError): + check_type(subject, predicate_type) + + +class TestProtocol: + @pytest.mark.parametrize( + "instantiate", + [pytest.param(True, id="instance"), pytest.param(False, id="class")], + ) + def test_success(self, typing_provider: Any, instantiate: bool) -> None: + class MyProtocol(Protocol): + member: int + + def noargs(self) -> None: + pass + + def posonlyargs(self, a: int, b: str, /) -> None: + pass + + def posargs(self, a: int, b: str, c: float = 2.0) -> None: + pass + + def varargs(self, *args: Any) -> None: + pass + + def varkwargs(self, **kwargs: Any) -> None: + pass + + def varbothargs(self, *args: Any, **kwargs: Any) -> None: + pass + + @staticmethod + def my_static_method(x: int, y: str) -> None: + pass + + @classmethod + def my_class_method(cls, x: int, y: str) -> None: + pass + + class Foo: + member = 1 + + def noargs(self, x: int = 1) -> None: + pass + + def posonlyargs(self, a: int, b: str, c: float = 2.0, /) -> None: + pass + + def posargs(self, *args: Any) -> None: + pass + + def varargs(self, *args: Any, kwarg: str = "foo") -> None: + pass + + def varkwargs(self, **kwargs: Any) -> None: + pass + + def varbothargs(self, *args: Any, **kwargs: Any) -> None: + pass + + # These were intentionally reversed, as this is OK for mypy + @classmethod + def my_static_method(cls, x: int, y: str) -> None: + pass + + @staticmethod + def my_class_method(x: int, y: str) -> None: + pass + + if instantiate: + check_type(Foo(), MyProtocol) + else: + check_type(Foo, type[MyProtocol]) + + @pytest.mark.parametrize( + "instantiate", + [pytest.param(True, id="instance"), pytest.param(False, id="class")], + ) + @pytest.mark.parametrize("subject_class", [object, str, Parent]) + def test_empty_protocol(self, instantiate: bool, subject_class: type[Any]): + class EmptyProtocol(Protocol): + pass + + if instantiate: + check_type(subject_class(), EmptyProtocol) + else: + check_type(subject_class, type[EmptyProtocol]) + + @pytest.mark.parametrize("has_member", [True, False]) + def test_member_checks(self, has_member: bool) -> None: + class MyProtocol(Protocol): + member: int + + class Foo: + def __init__(self, member: int): + if member: + self.member = member + + if has_member: + check_type(Foo(1), MyProtocol) + else: + pytest.raises(TypeCheckError, check_type, Foo(0), MyProtocol).match( + f"^{qualified_name(Foo)} is not compatible with the " + f"{MyProtocol.__qualname__} protocol because it has no attribute named " + f"'member'" + ) + + def test_missing_method(self) -> None: + class MyProtocol(Protocol): + def meth(self) -> None: + pass + + class Foo: + pass + + pytest.raises(TypeCheckError, check_type, Foo(), MyProtocol).match( + f"^{qualified_name(Foo)} is not compatible with the " + f"{MyProtocol.__qualname__} protocol because it has no method named " + f"'meth'" + ) + + def test_too_many_posargs(self) -> None: + class MyProtocol(Protocol): + def meth(self) -> None: + pass + + class Foo: + def meth(self, x: str) -> None: + pass + + pytest.raises(TypeCheckError, check_type, Foo(), MyProtocol).match( + f"^{qualified_name(Foo)} is not compatible with the " + f"{MyProtocol.__qualname__} protocol because its 'meth' method has too " + f"many mandatory positional arguments" + ) + + def test_wrong_posarg_name(self) -> None: + class MyProtocol(Protocol): + def meth(self, x: str) -> None: + pass + + class Foo: + def meth(self, y: str) -> None: + pass + + pytest.raises(TypeCheckError, check_type, Foo(), MyProtocol).match( + rf"^{qualified_name(Foo)} is not compatible with the " + rf"{MyProtocol.__qualname__} protocol because its 'meth' method has a " + rf"positional argument \(y\) that should be named 'x' at this position" + ) + + def test_too_few_posargs(self) -> None: + class MyProtocol(Protocol): + def meth(self, x: str) -> None: + pass + + class Foo: + def meth(self) -> None: + pass + + pytest.raises(TypeCheckError, check_type, Foo(), MyProtocol).match( + f"^{qualified_name(Foo)} is not compatible with the " + f"{MyProtocol.__qualname__} protocol because its 'meth' method has too " + f"few positional arguments" + ) + + def test_no_varargs(self) -> None: + class MyProtocol(Protocol): + def meth(self, *args: Any) -> None: + pass + + class Foo: + def meth(self) -> None: + pass + + pytest.raises(TypeCheckError, check_type, Foo(), MyProtocol).match( + f"^{qualified_name(Foo)} is not compatible with the " + f"{MyProtocol.__qualname__} protocol because its 'meth' method should " + f"accept variable positional arguments but doesn't" + ) + + def test_no_kwargs(self) -> None: + class MyProtocol(Protocol): + def meth(self, **kwargs: Any) -> None: + pass + + class Foo: + def meth(self) -> None: + pass + + pytest.raises(TypeCheckError, check_type, Foo(), MyProtocol).match( + f"^{qualified_name(Foo)} is not compatible with the " + f"{MyProtocol.__qualname__} protocol because its 'meth' method should " + f"accept variable keyword arguments but doesn't" + ) + + def test_missing_kwarg(self) -> None: + class MyProtocol(Protocol): + def meth(self, *, x: str) -> None: + pass + + class Foo: + def meth(self) -> None: + pass + + pytest.raises(TypeCheckError, check_type, Foo(), MyProtocol).match( + f"^{qualified_name(Foo)} is not compatible with the " + f"{MyProtocol.__qualname__} protocol because its 'meth' method is " + f"missing keyword-only arguments: x" + ) + + def test_extra_kwarg(self) -> None: + class MyProtocol(Protocol): + def meth(self) -> None: + pass + + class Foo: + def meth(self, *, x: str) -> None: + pass + + pytest.raises(TypeCheckError, check_type, Foo(), MyProtocol).match( + f"^{qualified_name(Foo)} is not compatible with the " + f"{MyProtocol.__qualname__} protocol because its 'meth' method has " + f"mandatory keyword-only arguments not present in the protocol: x" + ) + + def test_instance_staticmethod_mismatch(self) -> None: + class MyProtocol(Protocol): + @staticmethod + def meth() -> None: + pass + + class Foo: + def meth(self) -> None: + pass + + pytest.raises(TypeCheckError, check_type, Foo(), MyProtocol).match( + f"^{qualified_name(Foo)} is not compatible with the " + f"{MyProtocol.__qualname__} protocol because its 'meth' method should " + f"be a static method but it's an instance method" + ) + + def test_instance_classmethod_mismatch(self) -> None: + class MyProtocol(Protocol): + @classmethod + def meth(cls) -> None: + pass + + class Foo: + def meth(self) -> None: + pass + + pytest.raises(TypeCheckError, check_type, Foo(), MyProtocol).match( + f"^{qualified_name(Foo)} is not compatible with the " + f"{MyProtocol.__qualname__} protocol because its 'meth' method should " + f"be a class method but it's an instance method" + ) + + +class TestRecursiveType: + def test_valid(self): + check_type({"a": [1, 2, 3]}, JSONType) + + def test_fail(self): + with pytest.raises( + TypeCheckError, + match=( + "dict did not match any element in the union:\n" + " str: is not an instance of str\n" + " float: is neither float or int\n" + " bool: is not an instance of bool\n" + " NoneType: is not an instance of NoneType\n" + " List\\[JSONType\\]: is not a list\n" + " Dict\\[str, JSONType\\]: value of key 'a' did not match any element " + "in the union:\n" + " str: is not an instance of str\n" + " float: is neither float or int\n" + " bool: is not an instance of bool\n" + " NoneType: is not an instance of NoneType\n" + " List\\[JSONType\\]: is not a list\n" + " Dict\\[str, JSONType\\]: is not a dict" + ), + ): + check_type({"a": (1, 2, 3)}, JSONType) + + +class TestAnnotated: + def test_valid(self): + check_type("aa", Annotated[str, "blah"]) + + def test_fail(self): + pytest.raises(TypeCheckError, check_type, 1, Annotated[str, "blah"]).match( + "int is not an instance of str" + ) + + +class TestLiteralString: + def test_valid(self): + check_type("aa", LiteralString) + + def test_fail(self): + pytest.raises(TypeCheckError, check_type, 1, LiteralString).match( + "int is not an instance of str" + ) + + +class TestTypeGuard: + def test_valid(self): + check_type(True, TypeGuard) + + def test_fail(self): + pytest.raises(TypeCheckError, check_type, 1, TypeGuard).match( + "int is not an instance of bool" + ) + + +@pytest.mark.parametrize( + "policy, contextmanager", + [ + pytest.param(ForwardRefPolicy.ERROR, pytest.raises(NameError), id="error"), + pytest.param(ForwardRefPolicy.WARN, pytest.warns(TypeHintWarning), id="warn"), + pytest.param(ForwardRefPolicy.IGNORE, nullcontext(), id="ignore"), + ], +) +def test_forward_reference_policy( + policy: ForwardRefPolicy, contextmanager: ContextManager +): + with contextmanager: + check_type(1, ForwardRef("Foo"), forward_ref_policy=policy) # noqa: F821 + + +def test_any(): + assert check_type("aa", Any) == "aa" + + +def test_suppressed_checking(): + with suppress_type_checks(): + assert check_type("aa", int) == "aa" + + +def test_suppressed_checking_exception(): + with pytest.raises(RuntimeError), suppress_type_checks(): + assert check_type("aa", int) == "aa" + raise RuntimeError + + pytest.raises(TypeCheckError, check_type, "aa", int) + + +def test_any_subclass(): + class Foo(SubclassableAny): + pass + + check_type(Foo(), int) + + +def test_none(): + check_type(None, None) + + +def test_return_checked_value(): + value = {"foo": 1} + assert check_type(value, Dict[str, int]) is value + + +def test_imported_str_forward_ref(): + value = {"foo": 1} + memo = TypeCheckMemo(globals(), locals()) + pattern = r"Skipping type check against 'Dict\[str, int\]'" + with pytest.warns(TypeHintWarning, match=pattern): + check_type_internal(value, "Dict[str, int]", memo) + + +def test_check_against_tuple_success(): + check_type(1, (float, Union[str, int])) + + +def test_check_against_tuple_failure(): + pytest.raises(TypeCheckError, check_type, "aa", (int, bytes)) diff --git a/tests/test_importhook.py b/tests/test_importhook.py new file mode 100644 index 0000000..39d8968 --- /dev/null +++ b/tests/test_importhook.py @@ -0,0 +1,69 @@ +import sys +import warnings +from importlib import import_module +from importlib.util import cache_from_source +from pathlib import Path + +import pytest + +from typeguard import TypeCheckError, TypeguardFinder, install_import_hook +from typeguard._importhook import OPTIMIZATION + +pytestmark = pytest.mark.filterwarnings("error:no type annotations present") +this_dir = Path(__file__).parent +dummy_module_path = this_dir / "dummymodule.py" +cached_module_path = Path( + cache_from_source(str(dummy_module_path), optimization=OPTIMIZATION) +) + + +def import_dummymodule(): + if cached_module_path.exists(): + cached_module_path.unlink() + + sys.path.insert(0, str(this_dir)) + try: + with install_import_hook(["dummymodule"]): + with warnings.catch_warnings(): + warnings.filterwarnings("error", module="typeguard") + module = import_module("dummymodule") + return module + finally: + sys.path.remove(str(this_dir)) + + +def test_blanket_import(): + dummymodule = import_dummymodule() + try: + pytest.raises(TypeCheckError, dummymodule.type_checked_func, 2, "3").match( + r'argument "y" \(str\) is not an instance of int' + ) + finally: + del sys.modules["dummymodule"] + + +def test_package_name_matching(): + """ + The path finder only matches configured (sub)packages. + """ + packages = ["ham", "spam.eggs"] + dummy_original_pathfinder = None + finder = TypeguardFinder(packages, dummy_original_pathfinder) + + assert finder.should_instrument("ham") + assert finder.should_instrument("ham.eggs") + assert finder.should_instrument("spam.eggs") + + assert not finder.should_instrument("spam") + assert not finder.should_instrument("ha") + assert not finder.should_instrument("spam_eggs") + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires ast.unparse()") +def test_debug_instrumentation(monkeypatch, capsys): + monkeypatch.setattr("typeguard.config.debug_instrumentation", True) + import_dummymodule() + out, err = capsys.readouterr() + path_str = str(dummy_module_path) + assert f"Source code of {path_str!r} after instrumentation:" in err + assert "class DummyClass" in err diff --git a/tests/test_instrumentation.py b/tests/test_instrumentation.py new file mode 100644 index 0000000..74bab3e --- /dev/null +++ b/tests/test_instrumentation.py @@ -0,0 +1,344 @@ +import asyncio +import sys +import warnings +from importlib import import_module +from importlib.util import cache_from_source +from pathlib import Path + +import pytest +from pytest import FixtureRequest + +from typeguard import TypeCheckError, config, install_import_hook, suppress_type_checks +from typeguard._importhook import OPTIMIZATION + +pytestmark = pytest.mark.filterwarnings("error:no type annotations present") +this_dir = Path(__file__).parent +dummy_module_path = this_dir / "dummymodule.py" +cached_module_path = Path( + cache_from_source(str(dummy_module_path), optimization=OPTIMIZATION) +) + +# This block here is to test the recipe mentioned in the user guide +if "pytest" in sys.modules: + from typeguard import typechecked +else: + from typing import TypeVar + + _T = TypeVar("_T") + + def typechecked(target: _T, **kwargs) -> _T: + return target if target else typechecked + + +@pytest.fixture(scope="module", params=["typechecked", "importhook"]) +def method(request: FixtureRequest) -> str: + return request.param + + +@pytest.fixture(scope="module") +def dummymodule(method: str): + config.debug_instrumentation = True + sys.path.insert(0, str(this_dir)) + try: + sys.modules.pop("dummymodule", None) + if cached_module_path.exists(): + cached_module_path.unlink() + + if method == "typechecked": + return import_module("dummymodule") + + with install_import_hook(["dummymodule"]): + with warnings.catch_warnings(): + warnings.filterwarnings("error", module="typeguard") + module = import_module("dummymodule") + return module + finally: + sys.path.remove(str(this_dir)) + + +def test_type_checked_func(dummymodule): + assert dummymodule.type_checked_func(2, 3) == 6 + + +def test_type_checked_func_error(dummymodule): + pytest.raises(TypeCheckError, dummymodule.type_checked_func, 2, "3").match( + r'argument "y" \(str\) is not an instance of int' + ) + + +def test_non_type_checked_func(dummymodule): + assert dummymodule.non_type_checked_func("bah", 9) == "foo" + + +def test_non_type_checked_decorated_func(dummymodule): + assert dummymodule.non_type_checked_func("bah", 9) == "foo" + + +def test_typeguard_ignored_func(dummymodule): + assert dummymodule.non_type_checked_func("bah", 9) == "foo" + + +def test_type_checked_method(dummymodule): + instance = dummymodule.DummyClass() + pytest.raises(TypeCheckError, instance.type_checked_method, "bah", 9).match( + r'argument "x" \(str\) is not an instance of int' + ) + + +def test_type_checked_classmethod(dummymodule): + pytest.raises( + TypeCheckError, dummymodule.DummyClass.type_checked_classmethod, "bah", 9 + ).match(r'argument "x" \(str\) is not an instance of int') + + +def test_type_checked_staticmethod(dummymodule): + pytest.raises( + TypeCheckError, dummymodule.DummyClass.type_checked_staticmethod, "bah", 9 + ).match(r'argument "x" \(str\) is not an instance of int') + + +@pytest.mark.xfail(reason="No workaround for this has been implemented yet") +def test_inner_class_method(dummymodule): + retval = dummymodule.Outer().create_inner() + assert retval.__class__.__qualname__ == "Outer.Inner" + + +@pytest.mark.xfail(reason="No workaround for this has been implemented yet") +def test_inner_class_classmethod(dummymodule): + retval = dummymodule.Outer.create_inner_classmethod() + assert retval.__class__.__qualname__ == "Outer.Inner" + + +@pytest.mark.xfail(reason="No workaround for this has been implemented yet") +def test_inner_class_staticmethod(dummymodule): + retval = dummymodule.Outer.create_inner_staticmethod() + assert retval.__class__.__qualname__ == "Outer.Inner" + + +def test_local_class_instance(dummymodule): + instance = dummymodule.create_local_class_instance() + assert ( + instance.__class__.__qualname__ == "create_local_class_instance..Inner" + ) + + +def test_contextmanager(dummymodule): + with dummymodule.dummy_context_manager() as value: + assert value == 1 + + +def test_overload(dummymodule): + dummymodule.overloaded_func(1) + dummymodule.overloaded_func("x") + pytest.raises(TypeCheckError, dummymodule.overloaded_func, b"foo") + + +def test_async_func(dummymodule): + pytest.raises(TypeCheckError, asyncio.run, dummymodule.async_func(b"foo")) + + +def test_generator_valid(dummymodule): + gen = dummymodule.generator_func(6, "foo") + assert gen.send(None) == 6 + try: + gen.send(None) + except StopIteration as exc: + assert exc.value == "foo" + else: + pytest.fail("Generator did not exit") + + +def test_generator_bad_yield_type(dummymodule): + gen = dummymodule.generator_func("foo", "foo") + pytest.raises(TypeCheckError, gen.send, None).match( + r"yielded value \(str\) is not an instance of int" + ) + gen.close() + + +def test_generator_bad_return_type(dummymodule): + gen = dummymodule.generator_func(6, 6) + assert gen.send(None) == 6 + pytest.raises(TypeCheckError, gen.send, None).match( + r"return value \(int\) is not an instance of str" + ) + gen.close() + + +def test_asyncgen_valid(dummymodule): + gen = dummymodule.asyncgen_func(6) + assert asyncio.run(gen.asend(None)) == 6 + + +def test_asyncgen_bad_yield_type(dummymodule): + gen = dummymodule.asyncgen_func("foo") + pytest.raises(TypeCheckError, asyncio.run, gen.asend(None)).match( + r"yielded value \(str\) is not an instance of int" + ) + + +def test_missing_return(dummymodule): + pytest.raises(TypeCheckError, dummymodule.missing_return).match( + r"the return value \(None\) is not an instance of int" + ) + + +def test_pep_604_union_args(dummymodule): + pytest.raises(TypeCheckError, dummymodule.pep_604_union_args, 1.1).match( + r'argument "x" \(float\) did not match any element in the union:' + r"\n Callable\[list, Literal\[-1\]\]: is not callable" + r"\n Callable\[ellipsis, Union\[int, str\]\]: is not callable" + ) + + +def test_pep_604_union_retval(dummymodule): + pytest.raises(TypeCheckError, dummymodule.pep_604_union_retval, 1.1).match( + r"the return value \(float\) did not match any element in the union:" + r"\n str: is not an instance of str" + r"\n int: is not an instance of int" + ) + + +def test_builtin_generic_collections(dummymodule): + pytest.raises(TypeCheckError, dummymodule.builtin_generic_collections, 1.1).match( + r'argument "x" \(float\) is not a list' + ) + + +def test_paramspec(dummymodule): + def foo(a: int, b: str, *, c: bytes) -> None: + pass + + dummymodule.paramspec_function(foo, (1, "bar"), {"c": b"abc"}) + + +def test_augmented_assign(dummymodule): + assert dummymodule.aug_assign() == 2 + + +def test_multi_assign_single_value(dummymodule): + assert dummymodule.multi_assign_single_value() == (6, 6, 6) + + +def test_multi_assign_iterable(dummymodule): + assert dummymodule.multi_assign_iterable() == ([6, 7], [6, 7], [6, 7]) + + +def test_unpacking_assign(dummymodule): + assert dummymodule.unpacking_assign() == (1, "foo") + + +def test_unpacking_assign_from_generator(dummymodule): + assert dummymodule.unpacking_assign_generator() == (1, "foo") + + +def test_unpacking_assign_star_with_annotation(dummymodule): + assert dummymodule.unpacking_assign_star_with_annotation() == ( + 1, + [b"abc", b"bah"], + "foo", + ) + + +def test_unpacking_assign_star_no_annotation_success(dummymodule): + assert dummymodule.unpacking_assign_star_no_annotation( + (1, b"abc", b"bah", "foo") + ) == ( + 1, + [b"abc", b"bah"], + "foo", + ) + + +def test_unpacking_assign_star_no_annotation_fail(dummymodule): + with pytest.raises( + TypeCheckError, match=r"value assigned to z \(bytes\) is not an instance of str" + ): + dummymodule.unpacking_assign_star_no_annotation((1, b"abc", b"bah", b"foo")) + + +class TestOptionsOverride: + def test_forward_ref_policy(self, dummymodule): + with pytest.raises(NameError, match="name 'NonexistentType' is not defined"): + dummymodule.override_forward_ref_policy(6) + + def test_typecheck_fail_callback(self, dummymodule, capsys): + dummymodule.override_typecheck_fail_callback("foo") + assert capsys.readouterr().out == ( + 'argument "value" (str) is not an instance of int\n' + ) + + def test_override_collection_check_strategy(self, dummymodule): + with pytest.raises( + TypeCheckError, + match=r'item 1 of argument "value" \(list\) is not an instance of int', + ): + dummymodule.override_collection_check_strategy([1, "foo"]) + + def test_outer_class_typecheck_fail_callback(self, dummymodule, capsys): + dummymodule.OverrideClass().override_typecheck_fail_callback("foo") + assert capsys.readouterr().out == ( + 'argument "value" (str) is not an instance of int\n' + ) + + def test_inner_class_no_overrides(self, dummymodule): + with pytest.raises(TypeCheckError): + dummymodule.OverrideClass.Inner().override_typecheck_fail_callback("foo") + + +class TestVariableArguments: + def test_success(self, dummymodule): + assert dummymodule.typed_variable_args("foo", "bar", a=1, b=8) == ( + ("foo", "bar"), + {"a": 1, "b": 8}, + ) + + def test_args_fail(self, dummymodule): + with pytest.raises( + TypeCheckError, + match=r'item 0 of argument "args" \(tuple\) is not an instance of str', + ): + dummymodule.typed_variable_args(1, a=1, b=8) + + def test_kwargs_fail(self, dummymodule): + with pytest.raises( + TypeCheckError, + match=r'value of key \'a\' of argument "kwargs" \(dict\) is not an ' + r"instance of int", + ): + dummymodule.typed_variable_args("foo", "bar", a="baz") + + +class TestGuardedType: + def test_plain(self, dummymodule): + assert dummymodule.guarded_type_hint_plain("foo") == "foo" + + def test_subscript_toplevel(self, dummymodule): + assert dummymodule.guarded_type_hint_subscript_toplevel("foo") == "foo" + + def test_subscript_nested(self, dummymodule): + assert dummymodule.guarded_type_hint_subscript_nested(["foo"]) == ["foo"] + + +def test_literal(dummymodule): + assert dummymodule.literal("foo") == "foo" + + +def test_literal_in_union(dummymodule): + """Regression test for #372.""" + assert dummymodule.literal_in_union("foo") == "foo" + + +def test_typevar_forwardref(dummymodule): + instance = dummymodule.typevar_forwardref(dummymodule.DummyClass) + assert isinstance(instance, dummymodule.DummyClass) + + +def test_suppress_annotated_assignment(dummymodule): + with suppress_type_checks(): + assert dummymodule.literal_in_union("foo") == "foo" + + +def test_suppress_annotated_multi_assignment(dummymodule): + with suppress_type_checks(): + assert dummymodule.multi_assign_single_value() == (6, 6, 6) diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000..f01a074 --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,26 @@ +from pytest import MonkeyPatch + +from typeguard import load_plugins + + +def test_custom_type_checker(monkeypatch: MonkeyPatch) -> None: + def lookup_func(origin_type, args, extras): + pass + + class FakeEntryPoint: + name = "test" + + def load(self): + return lookup_func + + def fake_entry_points(group): + assert group == "typeguard.checker_lookup" + return [FakeEntryPoint()] + + checker_lookup_functions = [] + monkeypatch.setattr("typeguard._checkers.entry_points", fake_entry_points) + monkeypatch.setattr( + "typeguard._checkers.checker_lookup_functions", checker_lookup_functions + ) + load_plugins() + assert checker_lookup_functions[0] is lookup_func diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py new file mode 100644 index 0000000..0c5b04d --- /dev/null +++ b/tests/test_pytest_plugin.py @@ -0,0 +1,77 @@ +from textwrap import dedent + +import pytest +from pytest import MonkeyPatch, Pytester + +from typeguard import CollectionCheckStrategy, ForwardRefPolicy, TypeCheckConfiguration + + +@pytest.fixture +def config(monkeypatch: MonkeyPatch) -> TypeCheckConfiguration: + config = TypeCheckConfiguration() + monkeypatch.setattr("typeguard._pytest_plugin.global_config", config) + return config + + +def test_config_options(pytester: Pytester, config: TypeCheckConfiguration) -> None: + pytester.makepyprojecttoml( + ''' + [tool.pytest.ini_options] + typeguard-packages = """ + mypackage + otherpackage""" + typeguard-debug-instrumentation = true + typeguard-typecheck-fail-callback = "mypackage:failcallback" + typeguard-forward-ref-policy = "ERROR" + typeguard-collection-check-strategy = "ALL_ITEMS" + ''' + ) + pytester.makepyfile( + mypackage=( + dedent( + """ + def failcallback(): + pass + """ + ) + ) + ) + + pytester.plugins = ["typeguard"] + pytester.syspathinsert() + pytestconfig = pytester.parseconfigure() + assert pytestconfig.getini("typeguard-packages") == ["mypackage", "otherpackage"] + assert config.typecheck_fail_callback.__name__ == "failcallback" + assert config.debug_instrumentation is True + assert config.forward_ref_policy is ForwardRefPolicy.ERROR + assert config.collection_check_strategy is CollectionCheckStrategy.ALL_ITEMS + + +def test_commandline_options( + pytester: Pytester, config: TypeCheckConfiguration +) -> None: + pytester.makepyfile( + mypackage=( + dedent( + """ + def failcallback(): + pass + """ + ) + ) + ) + + pytester.plugins = ["typeguard"] + pytester.syspathinsert() + pytestconfig = pytester.parseconfigure( + "--typeguard-packages=mypackage,otherpackage", + "--typeguard-typecheck-fail-callback=mypackage:failcallback", + "--typeguard-debug-instrumentation", + "--typeguard-forward-ref-policy=ERROR", + "--typeguard-collection-check-strategy=ALL_ITEMS", + ) + assert pytestconfig.getoption("typeguard_packages") == "mypackage,otherpackage" + assert config.typecheck_fail_callback.__name__ == "failcallback" + assert config.debug_instrumentation is True + assert config.forward_ref_policy is ForwardRefPolicy.ERROR + assert config.collection_check_strategy is CollectionCheckStrategy.ALL_ITEMS diff --git a/tests/test_suppression.py b/tests/test_suppression.py new file mode 100644 index 0000000..47c433c --- /dev/null +++ b/tests/test_suppression.py @@ -0,0 +1,68 @@ +import pytest + +from typeguard import TypeCheckError, check_type, suppress_type_checks, typechecked + + +def test_contextmanager_typechecked(): + @typechecked + def foo(x: str) -> None: + pass + + with suppress_type_checks(): + foo(1) + + +def test_contextmanager_check_type(): + with suppress_type_checks(): + check_type(1, str) + + +def test_contextmanager_nesting(): + with suppress_type_checks(), suppress_type_checks(): + check_type(1, str) + + pytest.raises(TypeCheckError, check_type, 1, str) + + +def test_contextmanager_exception(): + """ + Test that type check suppression stops even if an exception is raised within the + context manager block. + + """ + with pytest.raises(RuntimeError): + with suppress_type_checks(): + raise RuntimeError + + pytest.raises(TypeCheckError, check_type, 1, str) + + +@suppress_type_checks +def test_decorator_typechecked(): + @typechecked + def foo(x: str) -> None: + pass + + foo(1) + + +@suppress_type_checks +def test_decorator_check_type(): + check_type(1, str) + + +def test_decorator_exception(): + """ + Test that type check suppression stops even if an exception is raised from a + decorated function. + + """ + + @suppress_type_checks + def foo(): + raise RuntimeError + + with pytest.raises(RuntimeError): + foo() + + pytest.raises(TypeCheckError, check_type, 1, str) diff --git a/tests/test_transformer.py b/tests/test_transformer.py new file mode 100644 index 0000000..2e18a5a --- /dev/null +++ b/tests/test_transformer.py @@ -0,0 +1,1972 @@ +import sys +from ast import parse, unparse +from textwrap import dedent + +import pytest + +from typeguard._transformer import TypeguardTransformer + + +def test_arguments_only() -> None: + node = parse( + dedent( + """ + def foo(x: int) -> None: + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types + + def foo(x: int) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, int)}, memo) + """ + ).strip() + ) + + +def test_return_only() -> None: + node = parse( + dedent( + """ + def foo(x) -> int: + return 6 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_return_type + + def foo(x) -> int: + memo = TypeCheckMemo(globals(), locals()) + return check_return_type('foo', 6, int, memo) + """ + ).strip() + ) + + +class TestGenerator: + def test_yield(self) -> None: + node = parse( + dedent( + """ + from collections.abc import Generator + from typing import Any + + def foo(x) -> Generator[int, Any, str]: + yield 2 + yield 6 + return 'test' + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_return_type, check_yield_type + from collections.abc import Generator + from typing import Any + + def foo(x) -> Generator[int, Any, str]: + memo = TypeCheckMemo(globals(), locals()) + yield check_yield_type('foo', 2, int, memo) + yield check_yield_type('foo', 6, int, memo) + return check_return_type('foo', 'test', str, memo) + """ + ).strip() + ) + + def test_no_return_type_check(self) -> None: + node = parse( + dedent( + """ + from collections.abc import Generator + + def foo(x) -> Generator[int, None, None]: + yield 2 + yield 6 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_send_type, check_yield_type + from collections.abc import Generator + + def foo(x) -> Generator[int, None, None]: + memo = TypeCheckMemo(globals(), locals()) + check_send_type('foo', (yield check_yield_type('foo', 2, int, \ +memo)), None, memo) + check_send_type('foo', (yield check_yield_type('foo', 6, int, \ +memo)), None, memo) + """ + ).strip() + ) + + def test_no_send_type_check(self) -> None: + node = parse( + dedent( + """ + from typing import Any + from collections.abc import Generator + + def foo(x) -> Generator[int, Any, Any]: + yield 2 + yield 6 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_yield_type + from typing import Any + from collections.abc import Generator + + def foo(x) -> Generator[int, Any, Any]: + memo = TypeCheckMemo(globals(), locals()) + yield check_yield_type('foo', 2, int, memo) + yield check_yield_type('foo', 6, int, memo) + """ + ).strip() + ) + + +class TestAsyncGenerator: + def test_full(self) -> None: + node = parse( + dedent( + """ + from collections.abc import AsyncGenerator + + async def foo(x) -> AsyncGenerator[int, None]: + yield 2 + yield 6 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_send_type, check_yield_type + from collections.abc import AsyncGenerator + + async def foo(x) -> AsyncGenerator[int, None]: + memo = TypeCheckMemo(globals(), locals()) + check_send_type('foo', (yield check_yield_type('foo', 2, int, \ +memo)), None, memo) + check_send_type('foo', (yield check_yield_type('foo', 6, int, \ +memo)), None, memo) + """ + ).strip() + ) + + def test_no_yield_type_check(self) -> None: + node = parse( + dedent( + """ + from typing import Any + from collections.abc import AsyncGenerator + + async def foo() -> AsyncGenerator[Any, None]: + yield 2 + yield 6 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_send_type + from typing import Any + from collections.abc import AsyncGenerator + + async def foo() -> AsyncGenerator[Any, None]: + memo = TypeCheckMemo(globals(), locals()) + check_send_type('foo', (yield 2), None, memo) + check_send_type('foo', (yield 6), None, memo) + """ + ).strip() + ) + + def test_no_send_type_check(self) -> None: + node = parse( + dedent( + """ + from typing import Any + from collections.abc import AsyncGenerator + + async def foo() -> AsyncGenerator[int, Any]: + yield 2 + yield 6 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_yield_type + from typing import Any + from collections.abc import AsyncGenerator + + async def foo() -> AsyncGenerator[int, Any]: + memo = TypeCheckMemo(globals(), locals()) + yield check_yield_type('foo', 2, int, memo) + yield check_yield_type('foo', 6, int, memo) + """ + ).strip() + ) + + +def test_pass_only() -> None: + node = parse( + dedent( + """ + def foo(x) -> None: + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + def foo(x) -> None: + pass + """ + ).strip() + ) + + +@pytest.mark.parametrize( + "import_line, decorator", + [ + pytest.param("from typing import no_type_check", "@no_type_check"), + pytest.param("from typeguard import typeguard_ignore", "@typeguard_ignore"), + pytest.param("import typing", "@typing.no_type_check"), + pytest.param("import typeguard", "@typeguard.typeguard_ignore"), + ], +) +def test_no_type_check_decorator(import_line: str, decorator: str) -> None: + node = parse( + dedent( + f""" + {import_line} + + {decorator} + def foo(x: int) -> int: + return x + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + f""" + {import_line} + + {decorator} + def foo(x: int) -> int: + return x + """ + ).strip() + ) + + +@pytest.mark.parametrize( + "import_line, annotation", + [ + pytest.param("from typing import Any", "Any"), + pytest.param("from typing import Any as AlterAny", "AlterAny"), + pytest.param("from typing_extensions import Any", "Any"), + pytest.param("from typing_extensions import Any as AlterAny", "AlterAny"), + pytest.param("import typing", "typing.Any"), + pytest.param("import typing as typing_alter", "typing_alter.Any"), + pytest.param("import typing_extensions as typing_alter", "typing_alter.Any"), + ], +) +def test_any_only(import_line: str, annotation: str) -> None: + node = parse( + dedent( + f""" + {import_line} + + def foo(x, y: {annotation}) -> {annotation}: + return 1 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + f""" + {import_line} + + def foo(x, y: {annotation}) -> {annotation}: + return 1 + """ + ).strip() + ) + + +def test_any_in_union() -> None: + node = parse( + dedent( + """ + from typing import Any, Union + + def foo(x, y: Union[Any, None]) -> Union[Any, None]: + return 1 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typing import Any, Union + + def foo(x, y: Union[Any, None]) -> Union[Any, None]: + return 1 + """ + ).strip() + ) + + +def test_any_in_pep_604_union() -> None: + node = parse( + dedent( + """ + from typing import Any + + def foo(x, y: Any | None) -> Any | None: + return 1 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typing import Any + + def foo(x, y: Any | None) -> Any | None: + return 1 + """ + ).strip() + ) + + +def test_any_in_nested_dict() -> None: + # Regression test for #373 + node = parse( + dedent( + """ + from typing import Any + + def foo(x: dict[str, dict[str, Any]]) -> None: + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types + from typing import Any + + def foo(x: dict[str, dict[str, Any]]) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, dict[str, dict[str, Any]])}, memo) + """ + ).strip() + ) + + +def test_avoid_global_names() -> None: + node = parse( + dedent( + """ + memo = TypeCheckMemo = check_argument_types = check_return_type = None + + def func1(x: int) -> int: + dummy = (memo,) + return x + + def func2(x: int) -> int: + return x + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo as TypeCheckMemo_ + from typeguard._functions import \ +check_argument_types as check_argument_types_, check_return_type as check_return_type_ + memo = TypeCheckMemo = check_argument_types = check_return_type = None + + def func1(x: int) -> int: + memo_ = TypeCheckMemo_(globals(), locals()) + check_argument_types_('func1', {'x': (x, int)}, memo_) + dummy = (memo,) + return check_return_type_('func1', x, int, memo_) + + def func2(x: int) -> int: + memo_ = TypeCheckMemo_(globals(), locals()) + check_argument_types_('func2', {'x': (x, int)}, memo_) + return check_return_type_('func2', x, int, memo_) + """ + ).strip() + ) + + +def test_avoid_local_names() -> None: + node = parse( + dedent( + """ + def foo(x: int) -> int: + memo = TypeCheckMemo = check_argument_types = check_return_type = None + return x + """ + ) + ) + TypeguardTransformer(["foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + def foo(x: int) -> int: + from typeguard import TypeCheckMemo as TypeCheckMemo_ + from typeguard._functions import \ +check_argument_types as check_argument_types_, check_return_type as check_return_type_ + memo_ = TypeCheckMemo_(globals(), locals()) + check_argument_types_('foo', {'x': (x, int)}, memo_) + memo = TypeCheckMemo = check_argument_types = check_return_type = None + return check_return_type_('foo', x, int, memo_) + """ + ).strip() + ) + + +def test_avoid_nonlocal_names() -> None: + node = parse( + dedent( + """ + def outer(): + memo = TypeCheckMemo = check_argument_types = check_return_type = None + + def foo(x: int) -> int: + return x + + return foo + """ + ) + ) + TypeguardTransformer(["outer", "foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + def outer(): + memo = TypeCheckMemo = check_argument_types = check_return_type = None + + def foo(x: int) -> int: + from typeguard import TypeCheckMemo as TypeCheckMemo_ + from typeguard._functions import \ +check_argument_types as check_argument_types_, check_return_type as check_return_type_ + memo_ = TypeCheckMemo_(globals(), locals()) + check_argument_types_('outer..foo', {'x': (x, int)}, memo_) + return check_return_type_('outer..foo', x, int, memo_) + return foo + """ + ).strip() + ) + + +def test_method() -> None: + node = parse( + dedent( + """ + class Foo: + def foo(self, x: int) -> int: + return x + """ + ) + ) + TypeguardTransformer(["Foo", "foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + class Foo: + + def foo(self, x: int) -> int: + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, \ +check_return_type + memo = TypeCheckMemo(globals(), locals(), self_type=self.__class__) + check_argument_types('Foo.foo', {'x': (x, int)}, memo) + return check_return_type('Foo.foo', x, int, memo) + """ + ).strip() + ) + + +def test_method_posonlyargs() -> None: + node = parse( + dedent( + """ + class Foo: + def foo(self, x: int, /, y: str) -> int: + return x + """ + ) + ) + TypeguardTransformer(["Foo", "foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + class Foo: + + def foo(self, x: int, /, y: str) -> int: + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, \ +check_return_type + memo = TypeCheckMemo(globals(), locals(), self_type=self.__class__) + check_argument_types('Foo.foo', {'x': (x, int), 'y': (y, str)}, memo) + return check_return_type('Foo.foo', x, int, memo) + """ + ).strip() + ) + + +def test_classmethod() -> None: + node = parse( + dedent( + """ + class Foo: + @classmethod + def foo(cls, x: int) -> int: + return x + """ + ) + ) + TypeguardTransformer(["Foo", "foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + class Foo: + + @classmethod + def foo(cls, x: int) -> int: + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, \ +check_return_type + memo = TypeCheckMemo(globals(), locals(), self_type=cls) + check_argument_types('Foo.foo', {'x': (x, int)}, memo) + return check_return_type('Foo.foo', x, int, memo) + """ + ).strip() + ) + + +def test_classmethod_posonlyargs() -> None: + node = parse( + dedent( + """ + class Foo: + @classmethod + def foo(cls, x: int, /, y: str) -> int: + return x + """ + ) + ) + TypeguardTransformer(["Foo", "foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + class Foo: + + @classmethod + def foo(cls, x: int, /, y: str) -> int: + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, \ +check_return_type + memo = TypeCheckMemo(globals(), locals(), self_type=cls) + check_argument_types('Foo.foo', {'x': (x, int), 'y': (y, str)}, \ +memo) + return check_return_type('Foo.foo', x, int, memo) + """ + ).strip() + ) + + +def test_staticmethod() -> None: + node = parse( + dedent( + """ + class Foo: + @staticmethod + def foo(x: int) -> int: + return x + """ + ) + ) + TypeguardTransformer(["Foo", "foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + class Foo: + + @staticmethod + def foo(x: int) -> int: + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, \ +check_return_type + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('Foo.foo', {'x': (x, int)}, memo) + return check_return_type('Foo.foo', x, int, memo) + """ + ).strip() + ) + + +def test_new_with_self() -> None: + node = parse( + dedent( + """ + from typing import Self + + class Foo: + def __new__(cls) -> Self: + return super().__new__(cls) + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_return_type + from typing import Self + + class Foo: + + def __new__(cls) -> Self: + Foo = cls + memo = TypeCheckMemo(globals(), locals(), self_type=cls) + return check_return_type('Foo.__new__', super().__new__(cls), \ +Self, memo) + """ + ).strip() + ) + + +def test_new_with_explicit_class_name() -> None: + # Regression test for #398 + node = parse( + dedent( + """ + class A: + + def __new__(cls) -> 'A': + return object.__new__(cls) + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_return_type + + class A: + + def __new__(cls) -> 'A': + A = cls + memo = TypeCheckMemo(globals(), locals(), self_type=cls) + return check_return_type('A.__new__', object.__new__(cls), A, memo) + """ + ).strip() + ) + + +def test_local_function() -> None: + node = parse( + dedent( + """ + def wrapper(): + def foo(x: int) -> int: + return x + + def foo2(x: int) -> int: + return x + + return foo + """ + ) + ) + TypeguardTransformer(["wrapper", "foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + def wrapper(): + + def foo(x: int) -> int: + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, \ +check_return_type + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('wrapper..foo', {'x': (x, int)}, memo) + return check_return_type('wrapper..foo', x, int, memo) + + def foo2(x: int) -> int: + return x + return foo + """ + ).strip() + ) + + +def test_function_local_class_method() -> None: + node = parse( + dedent( + """ + def wrapper(): + + class Foo: + + class Bar: + + def method(self, x: int) -> int: + return x + + def method2(self, x: int) -> int: + return x + """ + ) + ) + TypeguardTransformer(["wrapper", "Foo", "Bar", "method"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + def wrapper(): + + class Foo: + + class Bar: + + def method(self, x: int) -> int: + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, \ +check_return_type + memo = TypeCheckMemo(globals(), locals(), \ +self_type=self.__class__) + check_argument_types('wrapper..Foo.Bar.method', \ +{'x': (x, int)}, memo) + return check_return_type(\ +'wrapper..Foo.Bar.method', x, int, memo) + + def method2(self, x: int) -> int: + return x + """ + ).strip() + ) + + +def test_keyword_only_argument() -> None: + node = parse( + dedent( + """ + def foo(*, x: int) -> None: + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types + + def foo(*, x: int) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, int)}, memo) + """ + ).strip() + ) + + +def test_positional_only_argument() -> None: + node = parse( + dedent( + """ + def foo(x: int, /) -> None: + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types + + def foo(x: int, /) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, int)}, memo) + """ + ).strip() + ) + + +def test_variable_positional_argument() -> None: + node = parse( + dedent( + """ + def foo(*args: int) -> None: + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types + + def foo(*args: int) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'args': (args, tuple[int, ...])}, memo) + """ + ).strip() + ) + + +def test_variable_keyword_argument() -> None: + node = parse( + dedent( + """ + def foo(**kwargs: int) -> None: + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types + + def foo(**kwargs: int) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'kwargs': (kwargs, dict[str, int])}, memo) + """ + ).strip() + ) + + +class TestTypecheckingImport: + """ + Test that annotations imported conditionally on typing.TYPE_CHECKING are not used in + run-time checks. + """ + + def test_direct_references(self) -> None: + node = parse( + dedent( + """ + from typing import TYPE_CHECKING + if TYPE_CHECKING: + import typing + from typing import Hashable, Sequence + + def foo(x: Hashable, y: typing.Collection, *args: Hashable, \ +**kwargs: typing.Collection) -> Sequence: + bar: typing.Collection + baz: Hashable = 1 + return (1, 2) + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typing import TYPE_CHECKING + if TYPE_CHECKING: + import typing + from typing import Hashable, Sequence + + def foo(x: Hashable, y: typing.Collection, *args: Hashable, \ +**kwargs: typing.Collection) -> Sequence: + bar: typing.Collection + baz: Hashable = 1 + return (1, 2) + """ + ).strip() + ) + + def test_collection_parameter(self) -> None: + node = parse( + dedent( + """ + from typing import TYPE_CHECKING + if TYPE_CHECKING: + from nonexistent import FooBar + + def foo(x: list[FooBar]) -> list[FooBar]: + return x + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, check_return_type + from typing import TYPE_CHECKING + if TYPE_CHECKING: + from nonexistent import FooBar + + def foo(x: list[FooBar]) -> list[FooBar]: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, list)}, memo) + return check_return_type('foo', x, list, memo) + """ + ).strip() + ) + + def test_variable_annotations(self) -> None: + node = parse( + dedent( + """ + from typing import Any, TYPE_CHECKING + if TYPE_CHECKING: + from nonexistent import FooBar + + def foo(x: Any) -> None: + y: FooBar = x + z: list[FooBar] = [y] + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_variable_assignment + from typing import Any, TYPE_CHECKING + if TYPE_CHECKING: + from nonexistent import FooBar + + def foo(x: Any) -> None: + memo = TypeCheckMemo(globals(), locals()) + y: FooBar = x + z: list[FooBar] = check_variable_assignment([y], [[('z', list)]], \ +memo) + """ + ).strip() + ) + + def test_generator_function(self) -> None: + node = parse( + dedent( + """ + from typing import Any, TYPE_CHECKING + from collections.abc import Generator + if TYPE_CHECKING: + import typing + from typing import Hashable, Sequence + + def foo(x: Hashable, y: typing.Collection) -> Generator[Hashable, \ +typing.Collection, Sequence]: + yield 'foo' + return (1, 2) + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typing import Any, TYPE_CHECKING + from collections.abc import Generator + if TYPE_CHECKING: + import typing + from typing import Hashable, Sequence + + def foo(x: Hashable, y: typing.Collection) -> Generator[Hashable, \ +typing.Collection, Sequence]: + yield 'foo' + return (1, 2) + """ + ).strip() + ) + + def test_optional(self) -> None: + node = parse( + dedent( + """ + from typing import Any, Optional, TYPE_CHECKING + if TYPE_CHECKING: + from typing import Hashable + + def foo(x: Optional[Hashable]) -> Optional[Hashable]: + return x + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typing import Any, Optional, TYPE_CHECKING + if TYPE_CHECKING: + from typing import Hashable + + def foo(x: Optional[Hashable]) -> Optional[Hashable]: + return x + """ + ).strip() + ) + + def test_optional_nested(self) -> None: + node = parse( + dedent( + """ + from typing import Any, List, Optional + + def foo(x: List[Optional[int]]) -> None: + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types + from typing import Any, List, Optional + + def foo(x: List[Optional[int]]) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, List[Optional[int]])}, memo) + """ + ).strip() + ) + + def test_subscript_within_union(self) -> None: + # Regression test for #397 + node = parse( + dedent( + """ + from typing import Any, Iterable, Union, TYPE_CHECKING + if TYPE_CHECKING: + from typing import Hashable + + def foo(x: Union[Iterable[Hashable], str]) -> None: + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types + from typing import Any, Iterable, Union, TYPE_CHECKING + if TYPE_CHECKING: + from typing import Hashable + + def foo(x: Union[Iterable[Hashable], str]) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, Union[Iterable, str])}, memo) + """ + ).strip() + ) + + def test_pep604_union(self) -> None: + node = parse( + dedent( + """ + from typing import TYPE_CHECKING + if TYPE_CHECKING: + from typing import Hashable + + def foo(x: Hashable | str) -> None: + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typing import TYPE_CHECKING + if TYPE_CHECKING: + from typing import Hashable + + def foo(x: Hashable | str) -> None: + pass + """ + ).strip() + ) + + +class TestAssign: + def test_annotated_assign(self) -> None: + node = parse( + dedent( + """ + def foo() -> None: + x: int = otherfunc() + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_variable_assignment + + def foo() -> None: + memo = TypeCheckMemo(globals(), locals()) + x: int = check_variable_assignment(otherfunc(), [[('x', int)]], \ +memo) + """ + ).strip() + ) + + def test_varargs_assign(self) -> None: + node = parse( + dedent( + """ + def foo(*args: int) -> None: + args = (5,) + """ + ) + ) + TypeguardTransformer().visit(node) + + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, \ +check_variable_assignment + + def foo(*args: int) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'args': (args, \ +tuple[int, ...])}, memo) + args = check_variable_assignment((5,), \ +[[('args', tuple[int, ...])]], memo) + """ + ).strip() + ) + + def test_kwargs_assign(self) -> None: + node = parse( + dedent( + """ + def foo(**kwargs: int) -> None: + kwargs = {'a': 5} + """ + ) + ) + TypeguardTransformer().visit(node) + + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, \ +check_variable_assignment + + def foo(**kwargs: int) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'kwargs': (kwargs, \ +dict[str, int])}, memo) + kwargs = check_variable_assignment({'a': 5}, \ +[[('kwargs', dict[str, int])]], memo) + """ + ).strip() + ) + + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="Requires Python < 3.10") + def test_pep604_assign(self) -> None: + node = parse( + dedent( + """ + Union = None + + def foo() -> None: + x: int | str = otherfunc() + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_variable_assignment + from typing import Union as Union_ + Union = None + + def foo() -> None: + memo = TypeCheckMemo(globals(), locals()) + x: int | str = check_variable_assignment(otherfunc(), \ +[[('x', Union_[int, str])]], memo) + """ + ).strip() + ) + + def test_multi_assign(self) -> None: + node = parse( + dedent( + """ + def foo() -> None: + x: int + z: bytes + x, y, z = otherfunc() + """ + ) + ) + TypeguardTransformer().visit(node) + target = "x, y, z" if sys.version_info >= (3, 11) else "(x, y, z)" + assert ( + unparse(node) + == dedent( + f""" + from typeguard import TypeCheckMemo + from typeguard._functions import check_variable_assignment + from typing import Any + + def foo() -> None: + memo = TypeCheckMemo(globals(), locals()) + x: int + z: bytes + {target} = check_variable_assignment(otherfunc(), \ +[[('x', int), ('y', Any), ('z', bytes)]], memo) + """ + ).strip() + ) + + def test_star_multi_assign(self) -> None: + node = parse( + dedent( + """ + def foo() -> None: + x: int + z: bytes + x, *y, z = otherfunc() + """ + ) + ) + TypeguardTransformer().visit(node) + target = "x, *y, z" if sys.version_info >= (3, 11) else "(x, *y, z)" + assert ( + unparse(node) + == dedent( + f""" + from typeguard import TypeCheckMemo + from typeguard._functions import check_variable_assignment + from typing import Any + + def foo() -> None: + memo = TypeCheckMemo(globals(), locals()) + x: int + z: bytes + {target} = check_variable_assignment(otherfunc(), \ +[[('x', int), ('*y', Any), ('z', bytes)]], memo) + """ + ).strip() + ) + + def test_complex_multi_assign(self) -> None: + node = parse( + dedent( + """ + def foo() -> None: + x: int + z: bytes + all = x, *y, z = otherfunc() + """ + ) + ) + TypeguardTransformer().visit(node) + target = "x, *y, z" if sys.version_info >= (3, 11) else "(x, *y, z)" + assert ( + unparse(node) + == dedent( + f""" + from typeguard import TypeCheckMemo + from typeguard._functions import check_variable_assignment + from typing import Any + + def foo() -> None: + memo = TypeCheckMemo(globals(), locals()) + x: int + z: bytes + all = {target} = check_variable_assignment(otherfunc(), \ +[[('all', Any)], [('x', int), ('*y', Any), ('z', bytes)]], memo) + """ + ).strip() + ) + + def test_unpacking_assign_to_self(self) -> None: + node = parse( + dedent( + """ + class Foo: + + def foo(self) -> None: + x: int + (x, self.y) = 1, 'test' + """ + ) + ) + TypeguardTransformer().visit(node) + target = "x, self.y" if sys.version_info >= (3, 11) else "(x, self.y)" + assert ( + unparse(node) + == dedent( + f""" + from typeguard import TypeCheckMemo + from typeguard._functions import check_variable_assignment + from typing import Any + + class Foo: + + def foo(self) -> None: + memo = TypeCheckMemo(globals(), locals(), \ +self_type=self.__class__) + x: int + {target} = check_variable_assignment((1, 'test'), \ +[[('x', int), ('self.y', Any)]], memo) + """ + ).strip() + ) + + def test_assignment_annotated_argument(self) -> None: + node = parse( + dedent( + """ + def foo(x: int) -> None: + x = 6 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, \ +check_variable_assignment + + def foo(x: int) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, int)}, memo) + x = check_variable_assignment(6, [[('x', int)]], memo) + """ + ).strip() + ) + + def test_assignment_expr(self) -> None: + node = parse( + dedent( + """ + def foo() -> None: + x: int + if x := otherfunc(): + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_variable_assignment + + def foo() -> None: + memo = TypeCheckMemo(globals(), locals()) + x: int + if (x := check_variable_assignment(otherfunc(), 'x', int, \ +memo)): + pass + """ + ).strip() + ) + + def test_assignment_expr_annotated_argument(self) -> None: + node = parse( + dedent( + """ + def foo(x: int) -> None: + if x := otherfunc(): + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, \ +check_variable_assignment + + def foo(x: int) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, int)}, memo) + if (x := check_variable_assignment(otherfunc(), 'x', int, memo)): + pass + """ + ).strip() + ) + + @pytest.mark.parametrize( + "operator, function", + [ + pytest.param("+=", "iadd", id="add"), + pytest.param("-=", "isub", id="subtract"), + pytest.param("*=", "imul", id="multiply"), + pytest.param("@=", "imatmul", id="matrix_multiply"), + pytest.param("/=", "itruediv", id="div"), + pytest.param("//=", "ifloordiv", id="floordiv"), + pytest.param("**=", "ipow", id="power"), + pytest.param("<<=", "ilshift", id="left_bitshift"), + pytest.param(">>=", "irshift", id="right_bitshift"), + pytest.param("&=", "iand", id="and"), + pytest.param("^=", "ixor", id="xor"), + pytest.param("|=", "ior", id="or"), + ], + ) + def test_augmented_assignment(self, operator: str, function: str) -> None: + node = parse( + dedent( + f""" + def foo() -> None: + x: int + x {operator} 6 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + f""" + from typeguard import TypeCheckMemo + from typeguard._functions import check_variable_assignment + from operator import {function} + + def foo() -> None: + memo = TypeCheckMemo(globals(), locals()) + x: int + x = check_variable_assignment({function}(x, 6), [[('x', int)]], \ +memo) + """ + ).strip() + ) + + def test_augmented_assignment_non_annotated(self) -> None: + node = parse( + dedent( + """ + def foo() -> None: + x = 1 + x += 6 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + def foo() -> None: + x = 1 + x += 6 + """ + ).strip() + ) + + def test_augmented_assignment_annotated_argument(self) -> None: + node = parse( + dedent( + """ + def foo(x: int) -> None: + x += 6 + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, \ +check_variable_assignment + from operator import iadd + + def foo(x: int) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, int)}, memo) + x = check_variable_assignment(iadd(x, 6), [[('x', int)]], memo) + """ + ).strip() + ) + + +def test_argname_typename_conflicts() -> None: + node = parse( + dedent( + """ + from collections.abc import Generator + + def foo(x: kwargs, /, y: args, *args: x, baz: x, **kwargs: y) -> \ +Generator[args, x, kwargs]: + yield y + return x + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from collections.abc import Generator + + def foo(x: kwargs, /, y: args, *args: x, baz: x, **kwargs: y) -> \ +Generator[args, x, kwargs]: + yield y + return x + """ + ).strip() + ) + + +def test_local_assignment_typename_conflicts() -> None: + node = parse( + dedent( + """ + def foo() -> int: + int = 6 + return int + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + def foo() -> int: + int = 6 + return int + """ + ).strip() + ) + + +def test_local_ann_assignment_typename_conflicts() -> None: + node = parse( + dedent( + """ + from typing import Any + + def foo() -> int: + int: Any = 6 + return int + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typing import Any + + def foo() -> int: + int: Any = 6 + return int + """ + ).strip() + ) + + +def test_local_named_expr_typename_conflicts() -> None: + node = parse( + dedent( + """ + from typing import Any + + def foo() -> int: + if (int := 6): + pass + return int + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typing import Any + + def foo() -> int: + if (int := 6): + pass + return int + """ + ).strip() + ) + + +def test_dont_leave_empty_ast_container_nodes() -> None: + # Regression test for #352 + node = parse( + dedent( + """ + if True: + + class A: + ... + + def func(): + ... + + def foo(x: str) -> None: + pass + """ + ) + ) + TypeguardTransformer(["foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + if True: + pass + + def foo(x: str) -> None: + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, str)}, memo) + """ + ).strip() + ) + + +def test_dont_leave_empty_ast_container_nodes_2() -> None: + # Regression test for #352 + node = parse( + dedent( + """ + try: + + class A: + ... + + def func(): + ... + + except: + + class A: + ... + + def func(): + ... + + + def foo(x: str) -> None: + pass + """ + ) + ) + TypeguardTransformer(["foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + try: + pass + except: + pass + + def foo(x: str) -> None: + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, str)}, memo) + """ + ).strip() + ) + + +class TestTypeShadowedByArgument: + def test_typing_union(self) -> None: + # Regression test for #394 + node = parse( + dedent( + """ + from __future__ import annotations + from typing import Union + + class A: + ... + + def foo(A: Union[A, None]) -> None: + pass + """ + ) + ) + TypeguardTransformer(["foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + from __future__ import annotations + from typing import Union + + def foo(A: Union[A, None]) -> None: + pass + """ + ).strip() + ) + + def test_pep604_union(self) -> None: + # Regression test for #395 + node = parse( + dedent( + """ + from __future__ import annotations + + class A: + ... + + def foo(A: A | None) -> None: + pass + """ + ) + ) + TypeguardTransformer(["foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + from __future__ import annotations + + def foo(A: A | None) -> None: + pass + """ + ).strip() + ) + + +def test_dont_parse_annotated_2nd_arg() -> None: + # Regression test for #352 + node = parse( + dedent( + """ + from typing import Annotated + + def foo(x: Annotated[str, 'foo bar']) -> None: + pass + """ + ) + ) + TypeguardTransformer(["foo"]).visit(node) + assert ( + unparse(node) + == dedent( + """ + from typing import Annotated + + def foo(x: Annotated[str, 'foo bar']) -> None: + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, Annotated[str, 'foo bar'])}, memo) + """ + ).strip() + ) + + +def test_respect_docstring() -> None: + # Regression test for #359 + node = parse( + dedent( + ''' + def foo() -> int: + """This is a docstring.""" + return 1 + ''' + ) + ) + TypeguardTransformer(["foo"]).visit(node) + assert ( + unparse(node) + == dedent( + ''' + def foo() -> int: + """This is a docstring.""" + from typeguard import TypeCheckMemo + from typeguard._functions import check_return_type + memo = TypeCheckMemo(globals(), locals()) + return check_return_type('foo', 1, int, memo) + ''' + ).strip() + ) + + +def test_respect_future_import() -> None: + # Regression test for #385 + node = parse( + dedent( + ''' + """module docstring""" + from __future__ import annotations + + def foo() -> int: + return 1 + ''' + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + ''' + """module docstring""" + from __future__ import annotations + from typeguard import TypeCheckMemo + from typeguard._functions import check_return_type + + def foo() -> int: + memo = TypeCheckMemo(globals(), locals()) + return check_return_type('foo', 1, int, memo) + ''' + ).strip() + ) + + +def test_literal() -> None: + # Regression test for #399 + node = parse( + dedent( + """ + from typing import Literal + + def foo(x: Literal['a', 'b']) -> None: + pass + """ + ) + ) + TypeguardTransformer().visit(node) + assert ( + unparse(node) + == dedent( + """ + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types + from typing import Literal + + def foo(x: Literal['a', 'b']) -> None: + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('foo', {'x': (x, Literal['a', 'b'])}, memo) + """ + ).strip() + ) diff --git a/tests/test_typechecked.py b/tests/test_typechecked.py new file mode 100644 index 0000000..d56f3ae --- /dev/null +++ b/tests/test_typechecked.py @@ -0,0 +1,694 @@ +import asyncio +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path +from textwrap import dedent +from typing import ( + Any, + AsyncGenerator, + AsyncIterable, + AsyncIterator, + Dict, + Generator, + Iterable, + Iterator, + List, +) +from unittest.mock import Mock + +import pytest + +from typeguard import TypeCheckError, typechecked + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + + +class TestCoroutineFunction: + def test_success(self): + @typechecked + async def foo(a: int) -> str: + return "test" + + assert asyncio.run(foo(1)) == "test" + + def test_bad_arg(self): + @typechecked + async def foo(a: int) -> str: + return "test" + + with pytest.raises( + TypeCheckError, match=r'argument "a" \(str\) is not an instance of int' + ): + asyncio.run(foo("foo")) + + def test_bad_return(self): + @typechecked + async def foo(a: int) -> str: + return 1 + + with pytest.raises( + TypeCheckError, match=r"return value \(int\) is not an instance of str" + ): + asyncio.run(foo(1)) + + def test_any_return(self): + @typechecked + async def foo() -> Any: + return 1 + + assert asyncio.run(foo()) == 1 + + +class TestGenerator: + def test_generator_bare(self): + @typechecked + def genfunc() -> Generator: + val1 = yield 2 + val2 = yield 3 + val3 = yield 4 + return [val1, val2, val3] + + gen = genfunc() + with pytest.raises(StopIteration) as exc: + value = next(gen) + while True: + value = gen.send(str(value)) + assert isinstance(value, int) + + assert exc.value.value == ["2", "3", "4"] + + def test_generator_annotated(self): + @typechecked + def genfunc() -> Generator[int, str, List[str]]: + val1 = yield 2 + val2 = yield 3 + val3 = yield 4 + return [val1, val2, val3] + + gen = genfunc() + with pytest.raises(StopIteration) as exc: + value = next(gen) + while True: + value = gen.send(str(value)) + assert isinstance(value, int) + + assert exc.value.value == ["2", "3", "4"] + + def test_generator_iterable_bare(self): + @typechecked + def genfunc() -> Iterable: + yield 2 + yield 3 + yield 4 + + values = list(genfunc()) + assert values == [2, 3, 4] + + def test_generator_iterable_annotated(self): + @typechecked + def genfunc() -> Iterable[int]: + yield 2 + yield 3 + yield 4 + + values = list(genfunc()) + assert values == [2, 3, 4] + + def test_generator_iterator_bare(self): + @typechecked + def genfunc() -> Iterator: + yield 2 + yield 3 + yield 4 + + values = list(genfunc()) + assert values == [2, 3, 4] + + def test_generator_iterator_annotated(self): + @typechecked + def genfunc() -> Iterator[int]: + yield 2 + yield 3 + yield 4 + + values = list(genfunc()) + assert values == [2, 3, 4] + + def test_bad_yield_as_generator(self): + @typechecked + def genfunc() -> Generator[int, str, None]: + yield "foo" + + gen = genfunc() + with pytest.raises(TypeCheckError) as exc: + next(gen) + + exc.match(r"the yielded value \(str\) is not an instance of int") + + def test_bad_yield_as_iterable(self): + @typechecked + def genfunc() -> Iterable[int]: + yield "foo" + + gen = genfunc() + with pytest.raises(TypeCheckError) as exc: + next(gen) + + exc.match(r"the yielded value \(str\) is not an instance of int") + + def test_bad_yield_as_iterator(self): + @typechecked + def genfunc() -> Iterator[int]: + yield "foo" + + gen = genfunc() + with pytest.raises(TypeCheckError) as exc: + next(gen) + + exc.match(r"the yielded value \(str\) is not an instance of int") + + def test_generator_bad_send(self): + @typechecked + def genfunc() -> Generator[int, str, None]: + yield 1 + yield 2 + + pass + gen = genfunc() + next(gen) + with pytest.raises(TypeCheckError) as exc: + gen.send(2) + + exc.match(r"value sent to generator \(int\) is not an instance of str") + + def test_generator_bad_return(self): + @typechecked + def genfunc() -> Generator[int, str, str]: + yield 1 + return 6 + + gen = genfunc() + next(gen) + with pytest.raises(TypeCheckError) as exc: + gen.send("foo") + + exc.match(r"return value \(int\) is not an instance of str") + + def test_return_generator(self): + @typechecked + def genfunc() -> Generator[int, None, None]: + yield 1 + + @typechecked + def foo() -> Generator[int, None, None]: + return genfunc() + + foo() + + +class TestAsyncGenerator: + def test_async_generator_bare(self): + @typechecked + async def genfunc() -> AsyncGenerator: + values.append((yield 2)) + values.append((yield 3)) + values.append((yield 4)) + + async def run_generator(): + gen = genfunc() + value = await gen.asend(None) + with pytest.raises(StopAsyncIteration): + while True: + value = await gen.asend(str(value)) + assert isinstance(value, int) + + values = [] + asyncio.run(run_generator()) + assert values == ["2", "3", "4"] + + def test_async_generator_annotated(self): + @typechecked + async def genfunc() -> AsyncGenerator[int, str]: + values.append((yield 2)) + values.append((yield 3)) + values.append((yield 4)) + + async def run_generator(): + gen = genfunc() + value = await gen.asend(None) + with pytest.raises(StopAsyncIteration): + while True: + value = await gen.asend(str(value)) + assert isinstance(value, int) + + values = [] + asyncio.run(run_generator()) + assert values == ["2", "3", "4"] + + def test_generator_iterable_bare(self): + @typechecked + async def genfunc() -> AsyncIterable: + yield 2 + yield 3 + yield 4 + + async def run_generator(): + return [value async for value in genfunc()] + + assert asyncio.run(run_generator()) == [2, 3, 4] + + def test_generator_iterable_annotated(self): + @typechecked + async def genfunc() -> AsyncIterable[int]: + yield 2 + yield 3 + yield 4 + + async def run_generator(): + return [value async for value in genfunc()] + + assert asyncio.run(run_generator()) == [2, 3, 4] + + def test_generator_iterator_bare(self): + @typechecked + async def genfunc() -> AsyncIterator: + yield 2 + yield 3 + yield 4 + + async def run_generator(): + return [value async for value in genfunc()] + + assert asyncio.run(run_generator()) == [2, 3, 4] + + def test_generator_iterator_annotated(self): + @typechecked + async def genfunc() -> AsyncIterator[int]: + yield 2 + yield 3 + yield 4 + + async def run_generator(): + return [value async for value in genfunc()] + + assert asyncio.run(run_generator()) == [2, 3, 4] + + def test_async_bad_yield_as_generator(self): + @typechecked + async def genfunc() -> AsyncGenerator[int, str]: + yield "foo" + + gen = genfunc() + with pytest.raises(TypeCheckError) as exc: + next(gen.__anext__().__await__()) + + exc.match(r"the yielded value \(str\) is not an instance of int") + + def test_async_bad_yield_as_iterable(self): + @typechecked + async def genfunc() -> AsyncIterable[int]: + yield "foo" + + gen = genfunc() + with pytest.raises(TypeCheckError) as exc: + next(gen.__anext__().__await__()) + + exc.match(r"the yielded value \(str\) is not an instance of int") + + def test_async_bad_yield_as_iterator(self): + @typechecked + async def genfunc() -> AsyncIterator[int]: + yield "foo" + + gen = genfunc() + with pytest.raises(TypeCheckError) as exc: + next(gen.__anext__().__await__()) + + exc.match(r"the yielded value \(str\) is not an instance of int") + + def test_async_generator_bad_send(self): + @typechecked + async def genfunc() -> AsyncGenerator[int, str]: + yield 1 + yield 2 + + gen = genfunc() + pytest.raises(StopIteration, next, gen.__anext__().__await__()) + with pytest.raises(TypeCheckError) as exc: + next(gen.asend(2).__await__()) + + exc.match(r"the value sent to generator \(int\) is not an instance of str") + + def test_return_async_generator(self): + @typechecked + async def genfunc() -> AsyncGenerator[int, None]: + yield 1 + + @typechecked + def foo() -> AsyncGenerator[int, None]: + return genfunc() + + foo() + + def test_async_generator_iterate(self): + @typechecked + async def asyncgenfunc() -> AsyncGenerator[int, None]: + yield 1 + + asyncgen = asyncgenfunc() + aiterator = asyncgen.__aiter__() + exc = pytest.raises(StopIteration, aiterator.__anext__().send, None) + assert exc.value.value == 1 + + +class TestSelf: + def test_return_valid(self): + class Foo: + @typechecked + def method(self) -> Self: + return self + + Foo().method() + + def test_return_invalid(self): + class Foo: + @typechecked + def method(self) -> Self: + return 1 + + foo = Foo() + pytest.raises(TypeCheckError, foo.method).match( + rf"the return value \(int\) is not an instance of the self type " + rf"\({__name__}\.{self.__class__.__name__}\.test_return_invalid\." + rf"\.Foo\)" + ) + + def test_classmethod_return_valid(self): + class Foo: + @classmethod + @typechecked + def method(cls) -> Self: + return Foo() + + Foo.method() + + def test_classmethod_return_invalid(self): + class Foo: + @classmethod + @typechecked + def method(cls) -> Self: + return 1 + + pytest.raises(TypeCheckError, Foo.method).match( + rf"the return value \(int\) is not an instance of the self type " + rf"\({__name__}\.{self.__class__.__name__}\." + rf"test_classmethod_return_invalid\.\.Foo\)" + ) + + def test_arg_valid(self): + class Foo: + @typechecked + def method(self, another: Self) -> None: + pass + + foo = Foo() + foo2 = Foo() + foo.method(foo2) + + def test_arg_invalid(self): + class Foo: + @typechecked + def method(self, another: Self) -> None: + pass + + foo = Foo() + pytest.raises(TypeCheckError, foo.method, 1).match( + rf'argument "another" \(int\) is not an instance of the self type ' + rf"\({__name__}\.{self.__class__.__name__}\.test_arg_invalid\." + rf"\.Foo\)" + ) + + def test_classmethod_arg_valid(self): + class Foo: + @classmethod + @typechecked + def method(cls, another: Self) -> None: + pass + + foo = Foo() + Foo.method(foo) + + def test_classmethod_arg_invalid(self): + class Foo: + @classmethod + @typechecked + def method(cls, another: Self) -> None: + pass + + foo = Foo() + pytest.raises(TypeCheckError, foo.method, 1).match( + rf'argument "another" \(int\) is not an instance of the self type ' + rf"\({__name__}\.{self.__class__.__name__}\." + rf"test_classmethod_arg_invalid\.\.Foo\)" + ) + + def test_self_type_valid(self): + class Foo: + @typechecked + def method(cls, subclass: type[Self]) -> None: + pass + + class Bar(Foo): + pass + + Foo().method(Bar) + + def test_self_type_invalid(self): + class Foo: + @typechecked + def method(cls, subclass: type[Self]) -> None: + pass + + pytest.raises(TypeCheckError, Foo().method, int).match( + rf'argument "subclass" \(class int\) is not a subclass of the self type ' + rf"\({__name__}\.{self.__class__.__name__}\." + rf"test_self_type_invalid\.\.Foo\)" + ) + + +class TestMock: + def test_mock_argument(self): + @typechecked + def foo(x: int) -> None: + pass + + foo(Mock()) + + def test_return_mock(self): + @typechecked + def foo() -> int: + return Mock() + + foo() + + +def test_decorator_before_classmethod(): + class Foo: + @typechecked + @classmethod + def method(cls, x: int) -> None: + pass + + pytest.raises(TypeCheckError, Foo().method, "bar").match( + r'argument "x" \(str\) is not an instance of int' + ) + + +def test_classmethod(): + @typechecked + class Foo: + @classmethod + def method(cls, x: int) -> None: + pass + + pytest.raises(TypeCheckError, Foo().method, "bar").match( + r'argument "x" \(str\) is not an instance of int' + ) + + +def test_decorator_before_staticmethod(): + class Foo: + @typechecked + @staticmethod + def method(x: int) -> None: + pass + + pytest.raises(TypeCheckError, Foo().method, "bar").match( + r'argument "x" \(str\) is not an instance of int' + ) + + +def test_staticmethod(): + @typechecked + class Foo: + @staticmethod + def method(x: int) -> None: + pass + + pytest.raises(TypeCheckError, Foo().method, "bar").match( + r'argument "x" \(str\) is not an instance of int' + ) + + +def test_retain_dunder_attributes(): + @typechecked + def foo(x: int, y: str = "foo") -> None: + """This is a docstring.""" + + assert foo.__module__ == __name__ + assert foo.__name__ == "foo" + assert foo.__qualname__ == "test_retain_dunder_attributes..foo" + assert foo.__doc__ == "This is a docstring." + assert foo.__defaults__ == ("foo",) + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires ast.unparse()") +def test_debug_instrumentation(monkeypatch, capsys): + monkeypatch.setattr("typeguard.config.debug_instrumentation", True) + + @typechecked + def foo(a: str) -> int: + return 6 + + out, err = capsys.readouterr() + assert err == dedent( + """\ + Source code of test_debug_instrumentation..foo() after instrumentation: + ---------------------------------------------- + def foo(a: str) -> int: + from typeguard import TypeCheckMemo + from typeguard._functions import check_argument_types, check_return_type + memo = TypeCheckMemo(globals(), locals()) + check_argument_types('test_debug_instrumentation..foo', \ +{'a': (a, str)}, memo) + return check_return_type('test_debug_instrumentation..foo', 6, \ +int, memo) + ---------------------------------------------- + """ + ) + + +def test_keyword_argument_default(): + # Regression test for #305 + @typechecked + def foo(*args, x: "int | None" = None): + pass + + foo() + + +def test_return_type_annotation_refers_to_nonlocal(): + class Internal: + pass + + @typechecked + def foo() -> Internal: + return Internal() + + assert isinstance(foo(), Internal) + + +def test_existing_method_decorator(): + @typechecked + class Foo: + @contextmanager + def method(self, x: int) -> None: + yield x + 1 + + with Foo().method(6) as value: + assert value == 7 + + +@pytest.mark.parametrize( + "flags, expected_return_code", + [ + pytest.param([], 1, id="debug"), + pytest.param(["-O"], 0, id="O"), + pytest.param(["-OO"], 0, id="OO"), + ], +) +def test_typechecked_disabled_in_optimized_mode( + tmp_path: Path, flags: List[str], expected_return_code: int +): + code = dedent( + """ + from typeguard import typechecked + + @typechecked + def foo(x: int) -> None: + pass + + foo("a") + """ + ) + script_path = tmp_path / "code.py" + script_path.write_text(code) + process = subprocess.run( + [sys.executable, *flags, str(script_path)], capture_output=True + ) + assert process.returncode == expected_return_code + if process.returncode == 1: + assert process.stderr.strip().endswith( + b'typeguard.TypeCheckError: argument "x" (str) is not an instance of int' + ) + + +def test_reference_imported_name_from_method() -> None: + # Regression test for #362 + @typechecked + class A: + def foo(self) -> Dict[str, Any]: + return {} + + A().foo() + + +def test_getter_setter(): + """Regression test for #355.""" + + @typechecked + class Foo: + def __init__(self, x: int): + self._x = x + + @property + def x(self) -> int: + return self._x + + @x.setter + def x(self, value: int) -> None: + self._x = value + + f = Foo(1) + f.x = 2 + assert f.x == 2 + with pytest.raises(TypeCheckError): + f.x = "foo" + + +def test_duplicate_method(): + class Foo: + def x(self) -> str: + return "first" + + @typechecked() + def x(self, value: int) -> str: # noqa: F811 + return "second" + + assert Foo().x(1) == "second" + with pytest.raises(TypeCheckError): + Foo().x("wrong") diff --git a/tests/test_typeguard.py b/tests/test_typeguard.py deleted file mode 100644 index 52834eb..0000000 --- a/tests/test_typeguard.py +++ /dev/null @@ -1,874 +0,0 @@ -import sys -from concurrent.futures import ThreadPoolExecutor -from functools import wraps, partial -from io import StringIO -from typing import ( - Any, Callable, Dict, List, Set, Tuple, Union, TypeVar, Sequence, NamedTuple, Iterable, - Container, Generic) - -import pytest - -from typeguard import ( - typechecked, check_argument_types, qualified_name, TypeChecker, TypeWarning, function_name, - check_type) - -try: - from typing import Type -except ImportError: - Type = List # don't worry, Type is not actually used if this happens! - -try: - from typing import Collection -except ImportError: - Collection = None - - -class Parent: - pass - - -class Child(Parent): - def method(self, a: int): - pass - - -@pytest.mark.parametrize('inputval, expected', [ - (qualified_name, 'function'), - (Child(), 'test_typeguard.Child'), - (int, 'int') -], ids=['func', 'instance', 'builtintype']) -def test_qualified_name(inputval, expected): - assert qualified_name(inputval) == expected - - -def test_function_name(): - assert function_name(function_name) == 'typeguard.function_name' - - -def test_check_type_no_memo(): - check_type('foo', [1], List[int]) - - -def test_check_type_no_memo_fail(): - pytest.raises(TypeError, check_type, 'foo', ['a'], List[int]).\ - match('type of foo\[0\] must be int; got str instead') - - -class TestCheckArgumentTypes: - def test_any_type(self): - def foo(a: Any): - assert check_argument_types() - - foo('aa') - - def test_callable_exact_arg_count(self): - def foo(a: Callable[[int, str], int]): - assert check_argument_types() - - def some_callable(x: int, y: str) -> int: - pass - - foo(some_callable) - - def test_callable_bad_type(self): - def foo(a: Callable[..., int]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, 5) - assert str(exc.value) == 'argument "a" must be a callable' - - def test_callable_too_few_arguments(self): - def foo(a: Callable[[int, str], int]): - assert check_argument_types() - - def some_callable(x: int) -> int: - pass - - exc = pytest.raises(TypeError, foo, some_callable) - assert str(exc.value) == ( - 'callable passed as argument "a" has too few arguments in its declaration; expected 2 ' - 'but 1 argument(s) declared') - - def test_callable_too_many_arguments(self): - def foo(a: Callable[[int, str], int]): - assert check_argument_types() - - def some_callable(x: int, y: str, z: float) -> int: - pass - - exc = pytest.raises(TypeError, foo, some_callable) - assert str(exc.value) == ( - 'callable passed as argument "a" has too many arguments in its declaration; expected ' - '2 but 3 argument(s) declared') - - def test_callable_mandatory_kwonlyargs(self): - def foo(a: Callable[[int, str], int]): - assert check_argument_types() - - def some_callable(x: int, y: str, *, z: float, bar: str) -> int: - pass - - exc = pytest.raises(TypeError, foo, some_callable) - assert str(exc.value) == ( - 'callable passed as argument "a" has mandatory keyword-only arguments in its ' - 'declaration: z, bar') - - def test_callable_class(self): - """ - Test that passing a class as a callable does not count the "self" argument "a"gainst the - ones declared in the Callable specification. - - """ - def foo(a: Callable[[int, str], Any]): - assert check_argument_types() - - class SomeClass: - def __init__(self, x: int, y: str): - pass - - foo(SomeClass) - - def test_callable_plain(self): - def foo(a: Callable): - assert check_argument_types() - - def callback(a): - pass - - foo(callback) - - def test_callable_partial_class(self): - """ - Test that passing a bound method as a callable does not count the "self" argument "a"gainst - the ones declared in the Callable specification. - - """ - def foo(a: Callable[[int], Any]): - assert check_argument_types() - - class SomeClass: - def __init__(self, x: int, y: str): - pass - - foo(partial(SomeClass, y='foo')) - - def test_callable_bound_method(self): - """ - Test that passing a bound method as a callable does not count the "self" argument "a"gainst - the ones declared in the Callable specification. - - """ - def foo(callback: Callable[[int], Any]): - assert check_argument_types() - - foo(Child().method) - - def test_callable_partial_bound_method(self): - """ - Test that passing a bound method as a callable does not count the "self" argument "a"gainst - the ones declared in the Callable specification. - - """ - def foo(callback: Callable[[], Any]): - assert check_argument_types() - - foo(partial(Child().method, 1)) - - def test_callable_defaults(self): - """ - Test that a callable having "too many" arguments don't raise an error if the extra - arguments have default values. - - """ - def foo(callback: Callable[[int, str], Any]): - assert check_argument_types() - - def some_callable(x: int, y: str, z: float = 1.2) -> int: - pass - - foo(some_callable) - - def test_callable_builtin(self): - """ - Test that checking a Callable annotation against a builtin callable does not raise an - error. - - """ - def foo(callback: Callable[[int], Any]): - assert check_argument_types() - - foo([].append) - - def test_dict_bad_type(self): - def foo(a: Dict[str, int]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, 5) - assert str(exc.value) == ( - 'type of argument "a" must be a dict; got int instead') - - def test_dict_bad_key_type(self): - def foo(a: Dict[str, int]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, {1: 2}) - assert str(exc.value) == 'type of keys of argument "a" must be str; got int instead' - - def test_dict_bad_value_type(self): - def foo(a: Dict[str, int]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, {'x': 'a'}) - assert str(exc.value) == "type of argument \"a\"['x'] must be int; got str instead" - - def test_list_bad_type(self): - def foo(a: List[int]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, 5) - assert str(exc.value) == ( - 'type of argument "a" must be a list; got int instead') - - def test_list_bad_element(self): - def foo(a: List[int]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, [1, 2, 'bb']) - assert str(exc.value) == ( - 'type of argument "a"[2] must be int; got str instead') - - def test_sequence_bad_type(self): - def foo(a: Sequence[int]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, 5) - assert str(exc.value) == ( - 'type of argument "a" must be a sequence; got int instead') - - def test_sequence_bad_element(self): - def foo(a: Sequence[int]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, [1, 2, 'bb']) - assert str(exc.value) == ( - 'type of argument "a"[2] must be int; got str instead') - - def test_set_bad_type(self): - def foo(a: Set[int]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, 5) - assert str(exc.value) == 'type of argument "a" must be a set; got int instead' - - def test_set_bad_element(self): - def foo(a: Set[int]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, {1, 2, 'bb'}) - assert str(exc.value) == ( - 'type of elements of argument "a" must be int; got str instead') - - def test_tuple_bad_type(self): - def foo(a: Tuple[int]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, 5) - assert str(exc.value) == ( - 'type of argument "a" must be a tuple; got int instead') - - def test_tuple_too_many_elements(self): - def foo(a: Tuple[int, str]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, (1, 'aa', 2)) - assert str(exc.value) == ('argument "a" has wrong number of elements (expected 2, got 3 ' - 'instead)') - - def test_tuple_too_few_elements(self): - def foo(a: Tuple[int, str]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, (1,)) - assert str(exc.value) == ('argument "a" has wrong number of elements (expected 2, got 1 ' - 'instead)') - - def test_tuple_bad_element(self): - def foo(a: Tuple[int, str]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, (1, 2)) - assert str(exc.value) == ( - 'type of argument "a"[1] must be str; got int instead') - - def test_tuple_ellipsis_bad_element(self): - def foo(a: Tuple[int, ...]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, (1, 2, 'blah')) - assert str(exc.value) == ( - 'type of argument "a"[2] must be int; got str instead') - - def test_namedtuple(self): - Employee = NamedTuple('Employee', [('name', str), ('id', int)]) - - def foo(bar: Employee): - assert check_argument_types() - - foo(Employee('bob', 1)) - - def test_namedtuple_type_mismatch(self): - Employee = NamedTuple('Employee', [('name', str), ('id', int)]) - - def foo(bar: Employee): - assert check_argument_types() - - pytest.raises(TypeError, foo, ('bob', 1)).\ - match('type of argument "bar" must be a named tuple of type ' - '(test_typeguard\.)?Employee; got tuple instead') - - def test_namedtuple_wrong_field_type(self): - Employee = NamedTuple('Employee', [('name', str), ('id', int)]) - - def foo(bar: Employee): - assert check_argument_types() - - pytest.raises(TypeError, foo, Employee(2, 1)).\ - match('type of argument "bar".name must be str; got int instead') - - @pytest.mark.parametrize('value', [6, 'aa']) - def test_union(self, value): - def foo(a: Union[str, int]): - assert check_argument_types() - - foo(value) - - def test_union_typing_type(self): - def foo(a: Union[str, Collection]): - assert check_argument_types() - - with pytest.raises(TypeError): - foo(1) - - @pytest.mark.parametrize('value', [6.5, b'aa']) - def test_union_fail(self, value): - def foo(a: Union[str, int]): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, value) - assert str(exc.value) == ( - 'type of argument "a" must be one of (str, int); got {} instead'. - format(value.__class__.__name__)) - - @pytest.mark.parametrize('values', [ - (6, 7), - ('aa', 'bb') - ], ids=['int', 'str']) - def test_typevar_constraints(self, values): - T = TypeVar('T', int, str) - - def foo(a: T, b: T): - assert check_argument_types() - - foo(*values) - - def test_typevar_constraints_fail_typing_type(self): - T = TypeVar('T', int, Collection) - - def foo(a: T, b: T): - assert check_argument_types() - - with pytest.raises(TypeError): - foo('aa', 'bb') - - def test_typevar_constraints_fail(self): - T = TypeVar('T', int, str) - - def foo(a: T, b: T): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, 2.5, 'aa') - assert str(exc.value) == ('type of argument "a" must be one of (int, str); got float ' - 'instead') - - def test_typevar_bound(self): - T = TypeVar('T', bound=Parent) - - def foo(a: T, b: T): - assert check_argument_types() - - foo(Child(), Child()) - - def test_typevar_bound_fail(self): - T = TypeVar('T', bound=Child) - - def foo(a: T, b: T): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, Parent(), Parent()) - assert str(exc.value) == ('type of argument "a" must be test_typeguard.Child or one of ' - 'its subclasses; got test_typeguard.Parent instead') - - def test_typevar_invariant_fail(self): - T = TypeVar('T', int, str) - - def foo(a: T, b: T): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, 2, 3.6) - assert str(exc.value) == 'type of argument "b" must be exactly int; got float instead' - - def test_typevar_covariant(self): - T = TypeVar('T', covariant=True) - - def foo(a: T, b: T): - assert check_argument_types() - - foo(Parent(), Child()) - - def test_typevar_covariant_fail(self): - T = TypeVar('T', covariant=True) - - def foo(a: T, b: T): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, Child(), Parent()) - assert str(exc.value) == ('type of argument "b" must be test_typeguard.Child or one of ' - 'its subclasses; got test_typeguard.Parent instead') - - def test_typevar_contravariant(self): - T = TypeVar('T', contravariant=True) - - def foo(a: T, b: T): - assert check_argument_types() - - foo(Child(), Parent()) - - def test_typevar_contravariant_fail(self): - T = TypeVar('T', contravariant=True) - - def foo(a: T, b: T): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, Parent(), Child()) - assert str(exc.value) == ('type of argument "b" must be test_typeguard.Parent or one of ' - 'its superclasses; got test_typeguard.Child instead') - - @pytest.mark.skipif(Type is List, reason='typing.Type could not be imported') - def test_class_bad_subclass(self): - def foo(a: Type[Child]): - assert check_argument_types() - - pytest.raises(TypeError, foo, Parent).match( - '"a" must be a subclass of test_typeguard.Child; got test_typeguard.Parent instead') - - def test_wrapped_function(self): - def decorator(func): - @wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - return wrapper - - @decorator - def foo(a: 'Child'): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, Parent()) - assert str(exc.value) == ('type of argument "a" must be test_typeguard.Child; ' - 'got test_typeguard.Parent instead') - - def test_mismatching_default_type(self): - def foo(a: str = 1): - assert check_argument_types() - - pytest.raises(TypeError, foo).match('type of argument "a" must be str; got int instead') - - def test_implicit_default_none(self): - """ - Test that if the default value is ``None``, a ``None`` argument can be passed. - - """ - def foo(a: str=None): - assert check_argument_types() - - foo() - - def test_generator(self): - """Test that argument type checking works in a generator function too.""" - def generate(a: int): - assert check_argument_types() - yield a - yield a + 1 - - gen = generate(1) - next(gen) - - def test_varargs(self): - def foo(*args: int): - assert check_argument_types() - - foo(1, 2) - - def test_varargs_fail(self): - def foo(*args: int): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, 1, 'a') - exc.match('type of argument "args"\[1\] must be int; got str instead') - - def test_kwargs(self): - def foo(**kwargs: int): - assert check_argument_types() - - foo(a=1, b=2) - - def test_kwargs_fail(self): - def foo(**kwargs: int): - assert check_argument_types() - - exc = pytest.raises(TypeError, foo, a=1, b='a') - exc.match('type of argument "kwargs"\[\'b\'\] must be int; got str instead') - - def test_generic(self): - T_Foo = TypeVar('T_Foo') - - class FooGeneric(Generic[T_Foo]): - pass - - def foo(a: FooGeneric[str]): - assert check_argument_types() - - foo(FooGeneric[str]()) - - def test_newtype(self): - try: - from typing import NewType - except ImportError: - pytest.skip('Skipping newtype test since no NewType in current typing library') - - myint = NewType("myint", int) - - def foo(a: myint) -> int: - assert check_argument_types() - return 42 - - assert foo(1) == 42 - exc = pytest.raises(TypeError, foo, "a") - assert str(exc.value) == 'type of argument "a" must be int; got str instead' - - @pytest.mark.skipif(Collection is None, reason='typing.Collection is not available') - def test_collection(self): - def foo(a: Collection): - assert check_argument_types() - - pytest.raises(TypeError, foo, True).match( - 'type of argument "a" must be collections.abc.Collection; got bool instead') - - -class TestTypeChecked: - def test_typechecked(self): - @typechecked - def foo(a: int, b: str) -> str: - return 'abc' - - assert foo(4, 'abc') == 'abc' - - def test_typechecked_always(self): - @typechecked(always=True) - def foo(a: int, b: str) -> str: - return 'abc' - - assert foo(4, 'abc') == 'abc' - - def test_typechecked_arguments_fail(self): - @typechecked - def foo(a: int, b: str) -> str: - return 'abc' - - exc = pytest.raises(TypeError, foo, 4, 5) - assert str(exc.value) == 'type of argument "b" must be str; got int instead' - - def test_typechecked_return_type_fail(self): - @typechecked - def foo(a: int, b: str) -> str: - return 6 - - exc = pytest.raises(TypeError, foo, 4, 'abc') - assert str(exc.value) == 'type of the return value must be str; got int instead' - - def test_typechecked_return_typevar_fail(self): - T = TypeVar('T', int, float) - - @typechecked - def foo(a: T, b: T) -> T: - return 'a' - - exc = pytest.raises(TypeError, foo, 4, 2) - assert str(exc.value) == 'type of the return value must be exactly int; got str instead' - - def test_typechecked_no_annotations(self, recwarn): - def foo(a, b): - pass - - typechecked(foo) - - func_name = function_name(foo) - assert len(recwarn) == 1 - assert str(recwarn[0].message) == ( - 'no type annotations present -- not typechecking {}'.format(func_name)) - - def test_return_type_none(self): - """Check that a declared return type of None is respected.""" - @typechecked - def foo() -> None: - return 'a' - - exc = pytest.raises(TypeError, foo) - assert str(exc.value) == 'type of the return value must be NoneType; got str instead' - - @pytest.mark.parametrize('typehint', [ - Callable[..., int], - Callable - ], ids=['parametrized', 'unparametrized']) - def test_callable(self, typehint): - @typechecked - def foo(a: typehint): - pass - - def some_callable() -> int: - pass - - foo(some_callable) - - @pytest.mark.parametrize('typehint', [ - List[int], - List, - list, - ], ids=['parametrized', 'unparametrized', 'plain']) - def test_list(self, typehint): - @typechecked - def foo(a: typehint): - pass - - foo([1, 2]) - - @pytest.mark.parametrize('typehint', [ - Dict[str, int], - Dict, - dict - ], ids=['parametrized', 'unparametrized', 'plain']) - def test_dict(self, typehint): - @typechecked - def foo(a: typehint): - pass - - foo({'x': 2}) - - @pytest.mark.parametrize('typehint', [ - Sequence[str], - Sequence - ], ids=['parametrized', 'unparametrized']) - @pytest.mark.parametrize('value', [('a', 'b'), ['a', 'b'], 'abc'], - ids=['tuple', 'list', 'str']) - def test_sequence(self, typehint, value): - @typechecked - def foo(a: typehint): - pass - - foo(value) - - @pytest.mark.parametrize('typehint', [ - Iterable[str], - Iterable - ], ids=['parametrized', 'unparametrized']) - @pytest.mark.parametrize('value', [('a', 'b'), ['a', 'b'], 'abc'], - ids=['tuple', 'list', 'str']) - def test_iterable(self, typehint, value): - @typechecked - def foo(a: typehint): - pass - - foo(value) - - @pytest.mark.parametrize('typehint', [ - Container[str], - Container - ], ids=['parametrized', 'unparametrized']) - @pytest.mark.parametrize('value', [('a', 'b'), ['a', 'b'], 'abc'], - ids=['tuple', 'list', 'str']) - def test_container(self, typehint, value): - @typechecked - def foo(a: typehint): - pass - - foo(value) - - @pytest.mark.parametrize('typehint', [ - Set[int], - Set, - set - ], ids=['parametrized', 'unparametrized', 'plain']) - @pytest.mark.parametrize('value', [set(), {6}]) - def test_set(self, typehint, value): - @typechecked - def foo(a: typehint): - pass - - foo(value) - - @pytest.mark.parametrize('typehint', [ - Tuple[int, int], - Tuple[int, ...], - Tuple, - tuple - ], ids=['parametrized', 'ellipsis', 'unparametrized', 'plain']) - def test_tuple(self, typehint): - @typechecked - def foo(a: typehint): - pass - - foo((1, 2)) - - @pytest.mark.skipif(Type is List, reason='typing.Type could not be imported') - @pytest.mark.parametrize('typehint', [ - Type[Parent], - Type[TypeVar('UnboundType')], - Type[TypeVar('BoundType', bound=Parent)], - Type, - type - ], ids=['parametrized', 'unbound-typevar', 'bound-typevar', 'unparametrized', 'plain']) - def test_class(self, typehint): - @typechecked - def foo(a: typehint): - pass - - foo(Child) - - @pytest.mark.skipif(Type is List, reason='typing.Type could not be imported') - def test_class_not_a_class(self): - @typechecked - def foo(a: Type[dict]): - pass - - exc = pytest.raises(TypeError, foo, 1) - exc.match('type of argument "a" must be a type; got int instead') - - @pytest.mark.parametrize('typehint, value', [ - (complex, complex(1, 5)), - (complex, 1.0), - (complex, 1), - (float, 1.0), - (float, 1) - ], ids=['complex-complex', 'complex-float', 'complex-int', 'float-float', 'float-int']) - def test_numbers(self, typehint, value): - @typechecked - def foo(a: typehint): - pass - - foo(value) - - -class TestTypeChecker: - @pytest.fixture - def executor(self): - executor = ThreadPoolExecutor(1) - yield executor - executor.shutdown() - - @pytest.fixture - def checker(self): - return TypeChecker(__name__) - - def test_check_call_args(self, checker: TypeChecker): - def foo(a: int): - pass - - with checker, pytest.warns(TypeWarning) as record: - assert checker.active - foo(1) - foo('x') - - assert not checker.active - foo('x') - - assert len(record) == 1 - warning = record[0].message - assert warning.error == 'type of argument "a" must be int; got str instead' - assert warning.func is foo - assert isinstance(warning.stack, list) - buffer = StringIO() - warning.print_stack(buffer) - assert len(buffer.getvalue()) > 100 - - def test_check_return_value(self, checker: TypeChecker): - def foo() -> int: - return 'x' - - with checker, pytest.warns(TypeWarning) as record: - foo() - - assert len(record) == 1 - assert record[0].message.error == 'type of the return value must be int; got str instead' - - def test_threaded_check_call_args(self, checker: TypeChecker, executor): - def foo(a: int): - pass - - with checker, pytest.warns(TypeWarning) as record: - executor.submit(foo, 1).result() - executor.submit(foo, 'x').result() - - executor.submit(foo, 'x').result() - - assert len(record) == 1 - warning = record[0].message - assert warning.error == 'type of argument "a" must be int; got str instead' - assert warning.func is foo - - def test_double_start(self, checker: TypeChecker): - """Test that the same type checker can't be started twice while running.""" - with checker: - pytest.raises(RuntimeError, checker.start).match('type checker already running') - - def test_nested(self): - """Test that nesting of type checker context managers works as expected.""" - def foo(a: int): - pass - - with TypeChecker(__name__), pytest.warns(TypeWarning) as record: - foo('x') - with TypeChecker(__name__): - foo('x') - - assert len(record) == 3 - - def test_existing_profiler(self, checker: TypeChecker): - """ - Test that an existing profiler function is chained with the type checker and restored after - the block is exited. - - """ - def foo(a: int): - pass - - def profiler(frame, event, arg): - nonlocal profiler_run_count - if event in ('call', 'return'): - profiler_run_count += 1 - - if old_profiler: - old_profiler(frame, event, arg) - - profiler_run_count = 0 - old_profiler = sys.getprofile() - sys.setprofile(profiler) - try: - with checker, pytest.warns(TypeWarning) as record: - foo(1) - foo('x') - - assert sys.getprofile() is profiler - finally: - sys.setprofile(old_profiler) - - assert profiler_run_count - assert len(record) == 1 diff --git a/tests/test_union_transformer.py b/tests/test_union_transformer.py new file mode 100644 index 0000000..e6dcd25 --- /dev/null +++ b/tests/test_union_transformer.py @@ -0,0 +1,44 @@ +import typing +from typing import Callable, Union + +import pytest +from typing_extensions import Literal + +from typeguard._union_transformer import compile_type_hint + +eval_globals = { + "Callable": Callable, + "Literal": Literal, + "typing": typing, + "Union": Union, +} + + +@pytest.mark.parametrize( + "inputval, expected", + [ + ["str | int", "Union[str, int]"], + ["str | int | bytes", "Union[str, int, bytes]"], + ["str | Union[int | bytes, set]", "Union[str, int, bytes, set]"], + ["str | int | Callable[..., bytes]", "Union[str, int, Callable[..., bytes]]"], + ["str | int | Callable[[], bytes]", "Union[str, int, Callable[[], bytes]]"], + [ + "str | int | Callable[[], bytes | set]", + "Union[str, int, Callable[[], Union[bytes, set]]]", + ], + ["str | int | Literal['foo']", "Union[str, int, Literal['foo']]"], + ["str | int | Literal[-1]", "Union[str, int, Literal[-1]]"], + ["str | int | Literal[-1]", "Union[str, int, Literal[-1]]"], + [ + 'str | int | Literal["It\'s a string \'\\""]', + "Union[str, int, Literal['It\\'s a string \\'\"']]", + ], + ], +) +def test_union_transformer(inputval: str, expected: str) -> None: + code = compile_type_hint(inputval) + evaluated = eval(code, eval_globals) + evaluated_repr = repr(evaluated) + evaluated_repr = evaluated_repr.replace("typing.", "") + evaluated_repr = evaluated_repr.replace("typing_extensions.", "") + assert evaluated_repr == expected diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..e57a842 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,22 @@ +import pytest + +from typeguard._utils import function_name, qualified_name + +from . import Child + + +@pytest.mark.parametrize( + "inputval, add_class_prefix, expected", + [ + pytest.param(qualified_name, False, "function", id="func"), + pytest.param(Child(), False, "tests.Child", id="instance"), + pytest.param(int, False, "int", id="builtintype"), + pytest.param(int, True, "class int", id="builtintype_classprefix"), + ], +) +def test_qualified_name(inputval, add_class_prefix, expected): + assert qualified_name(inputval, add_class_prefix=add_class_prefix) == expected + + +def test_function_name(): + assert function_name(function_name) == "typeguard._utils.function_name" diff --git a/tests/test_warn_on_error.py b/tests/test_warn_on_error.py new file mode 100644 index 0000000..184b93b --- /dev/null +++ b/tests/test_warn_on_error.py @@ -0,0 +1,28 @@ +from typing import List + +import pytest + +from typeguard import TypeCheckWarning, check_type, config, typechecked, warn_on_error + + +def test_check_type(recwarn): + with pytest.warns(TypeCheckWarning) as warning: + check_type(1, str, typecheck_fail_callback=warn_on_error) + + assert len(warning.list) == 1 + assert warning.list[0].filename == __file__ + assert warning.list[0].lineno == test_check_type.__code__.co_firstlineno + 2 + + +def test_typechecked(monkeypatch, recwarn): + @typechecked + def foo() -> List[int]: + return ["aa"] # type: ignore[list-item] + + monkeypatch.setattr(config, "typecheck_fail_callback", warn_on_error) + with pytest.warns(TypeCheckWarning) as warning: + foo() + + assert len(warning.list) == 1 + assert warning.list[0].filename == __file__ + assert warning.list[0].lineno == test_typechecked.__code__.co_firstlineno + 3 diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 2b54c9b..0000000 --- a/tox.ini +++ /dev/null @@ -1,16 +0,0 @@ -[tox] -minversion = 2.5.0 -envlist = pypy3, py34, py35, py36, py37, flake8 -skip_missing_interpreters = true - -[testenv] -extras = testing -commands = python -m pytest {posargs} - -[testenv:pypy3] -ignore_outcome = true - -[testenv:flake8] -deps = flake8 -commands = flake8 typeguard.py tests -skip_install = true diff --git a/typeguard.py b/typeguard.py deleted file mode 100644 index cef1252..0000000 --- a/typeguard.py +++ /dev/null @@ -1,657 +0,0 @@ -__all__ = ('typechecked', 'check_argument_types', 'check_type', 'TypeWarning', 'TypeChecker') - -import collections.abc -import gc -import inspect -import sys -import threading -from collections import OrderedDict -from functools import wraps, partial -from inspect import Parameter, isclass, isfunction -from traceback import extract_stack, print_stack -from types import CodeType, FunctionType # noqa -from typing import (Callable, Any, Union, Dict, List, TypeVar, Tuple, Set, Sequence, - get_type_hints, TextIO, Optional) -from warnings import warn -from weakref import WeakKeyDictionary, WeakValueDictionary - -try: - from typing import Type -except ImportError: - Type = None - -_type_hints_map = WeakKeyDictionary() # type: Dict[FunctionType, Dict[str, Any]] -_functions_map = WeakValueDictionary() # type: Dict[CodeType, FunctionType] - - -class _CallMemo: - __slots__ = ('func', 'func_name', 'signature', 'typevars', 'arguments', 'type_hints') - - def __init__(self, func: Callable, frame=None, args: tuple = None, - kwargs: Dict[str, Any] = None): - self.func = func - self.func_name = function_name(func) - self.signature = inspect.signature(func) - self.typevars = {} # type: Dict[Any, type] - - if args is not None and kwargs is not None: - self.arguments = self.signature.bind(*args, **kwargs).arguments - else: - assert frame, 'frame must be specified if args or kwargs is None' - self.arguments = frame.f_locals.copy() - - self.type_hints = _type_hints_map.get(func) - if self.type_hints is None: - hints = get_type_hints(func) - self.type_hints = _type_hints_map[func] = OrderedDict() - for name, parameter in self.signature.parameters.items(): - if name in hints: - annotated_type = hints[name] - - # PEP 428 discourages it by MyPy does not complain - if parameter.default is None: - annotated_type = Optional[annotated_type] - - if parameter.kind == Parameter.VAR_POSITIONAL: - self.type_hints[name] = Tuple[annotated_type, ...] - elif parameter.kind == Parameter.VAR_KEYWORD: - self.type_hints[name] = Dict[str, annotated_type] - else: - self.type_hints[name] = annotated_type - - if 'return' in hints: - self.type_hints['return'] = hints['return'] - - -def get_type_name(type_): - # typing.* types don't have a __name__ on Python 3.7+ - return getattr(type_, '__name__', None) or type_._name - - -def find_function(frame) -> Optional[Callable]: - """ - Return a function object from the garbage collector that matches the frame's code object. - - This process is unreliable as several function objects could use the same code object. - Fortunately the likelihood of this happening with the combination of the function objects - having different type annotations is a very rare occurrence. - - :param frame: a frame object - :return: a function object if one was found, ``None`` if not - - """ - func = _functions_map.get(frame.f_code) - if func is None: - for obj in gc.get_referrers(frame.f_code): - if inspect.isfunction(obj): - if func is None: - # The first match was found - func = obj - else: - # A second match was found - return None - - # Cache the result for future lookups - if func is not None: - _functions_map[frame.f_code] = func - else: - raise LookupError('target function not found') - - return func - - -def qualified_name(obj) -> str: - """ - Return the qualified name (e.g. package.module.Type) for the given object. - - Builtins and types from the :mod:`typing` package get special treatment by having the module - name stripped from the generated name. - - """ - type_ = obj if inspect.isclass(obj) else type(obj) - module = type_.__module__ - qualname = type_.__qualname__ - return qualname if module in ('typing', 'builtins') else '{}.{}'.format(module, qualname) - - -def function_name(func: FunctionType) -> str: - """ - Return the qualified name of the given function. - - Builtins and types from the :mod:`typing` package get special treatment by having the module - name stripped from the generated name. - - """ - module = func.__module__ - qualname = func.__qualname__ - return qualname if module == 'builtins' else '{}.{}'.format(module, qualname) - - -def check_callable(argname: str, value, expected_type, memo: Optional[_CallMemo]) -> None: - if not callable(value): - raise TypeError('{} must be a callable'.format(argname)) - - if expected_type.__args__: - try: - signature = inspect.signature(value) - except (TypeError, ValueError): - return - - if hasattr(expected_type, '__result__'): - # Python 3.5 - argument_types = expected_type.__args__ - check_args = argument_types is not Ellipsis - else: - # Python 3.6 - argument_types = expected_type.__args__[:-1] - check_args = argument_types != (Ellipsis,) - - if check_args: - # The callable must not have keyword-only arguments without defaults - unfulfilled_kwonlyargs = [ - param.name for param in signature.parameters.values() if - param.kind == Parameter.KEYWORD_ONLY and param.default == Parameter.empty] - if unfulfilled_kwonlyargs: - raise TypeError( - 'callable passed as {} has mandatory keyword-only arguments in its ' - 'declaration: {}'.format(argname, ', '.join(unfulfilled_kwonlyargs))) - - num_mandatory_args = len([ - param.name for param in signature.parameters.values() - if param.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) and - param.default is Parameter.empty]) - has_varargs = any(param for param in signature.parameters.values() - if param.kind == Parameter.VAR_POSITIONAL) - - if num_mandatory_args > len(argument_types): - raise TypeError( - 'callable passed as {} has too many arguments in its declaration; expected {} ' - 'but {} argument(s) declared'.format(argname, len(argument_types), - num_mandatory_args)) - elif not has_varargs and num_mandatory_args < len(argument_types): - raise TypeError( - 'callable passed as {} has too few arguments in its declaration; expected {} ' - 'but {} argument(s) declared'.format(argname, len(argument_types), - num_mandatory_args)) - - -def check_dict(argname: str, value, expected_type, memo: Optional[_CallMemo]) -> None: - if not isinstance(value, dict): - raise TypeError('type of {} must be a dict; got {} instead'. - format(argname, qualified_name(value))) - - key_type, value_type = getattr(expected_type, '__args__', expected_type.__parameters__) - for k, v in value.items(): - check_type('keys of {}'.format(argname), k, key_type, memo) - check_type('{}[{!r}]'.format(argname, k), v, value_type, memo) - - -def check_list(argname: str, value, expected_type, memo: Optional[_CallMemo]) -> None: - if not isinstance(value, list): - raise TypeError('type of {} must be a list; got {} instead'. - format(argname, qualified_name(value))) - - value_type = getattr(expected_type, '__args__', expected_type.__parameters__)[0] - if value_type: - for i, v in enumerate(value): - check_type('{}[{}]'.format(argname, i), v, value_type, memo) - - -def check_sequence(argname: str, value, expected_type, memo: Optional[_CallMemo]) -> None: - if not isinstance(value, collections.Sequence): - raise TypeError('type of {} must be a sequence; got {} instead'. - format(argname, qualified_name(value))) - - value_type = getattr(expected_type, '__args__', expected_type.__parameters__)[0] - if value_type: - for i, v in enumerate(value): - check_type('{}[{}]'.format(argname, i), v, value_type, memo) - - -def check_set(argname: str, value, expected_type, memo: Optional[_CallMemo]) -> None: - if not isinstance(value, collections.Set): - raise TypeError('type of {} must be a set; got {} instead'. - format(argname, qualified_name(value))) - - value_type = getattr(expected_type, '__args__', expected_type.__parameters__)[0] - if value_type: - for v in value: - check_type('elements of {}'.format(argname), v, value_type, memo) - - -def check_tuple(argname: str, value, expected_type, memo: Optional[_CallMemo]) -> None: - # Specialized check for NamedTuples - if hasattr(expected_type, '_field_types'): - if not isinstance(value, expected_type): - raise TypeError('type of {} must be a named tuple of type {}; got {} instead'. - format(argname, qualified_name(expected_type), qualified_name(value))) - - for name, field_type in expected_type._field_types.items(): - check_type('{}.{}'.format(argname, name), getattr(value, name), field_type, memo) - - return - elif not isinstance(value, tuple): - raise TypeError('type of {} must be a tuple; got {} instead'. - format(argname, qualified_name(value))) - - if getattr(expected_type, '__tuple_params__', None): - # Python 3.5 - use_ellipsis = expected_type.__tuple_use_ellipsis__ - tuple_params = expected_type.__tuple_params__ - elif getattr(expected_type, '__args__', None): - # Python 3.6+ - use_ellipsis = expected_type.__args__[-1] is Ellipsis - tuple_params = expected_type.__args__[:-1 if use_ellipsis else None] - else: - # Unparametrized Tuple or plain tuple - return - - if use_ellipsis: - element_type = tuple_params[0] - for i, element in enumerate(value): - check_type('{}[{}]'.format(argname, i), element, element_type, memo) - else: - if len(value) != len(tuple_params): - raise TypeError('{} has wrong number of elements (expected {}, got {} instead)' - .format(argname, len(tuple_params), len(value))) - - for i, (element, element_type) in enumerate(zip(value, tuple_params)): - check_type('{}[{}]'.format(argname, i), element, element_type, memo) - - -def check_union(argname: str, value, expected_type, memo: Optional[_CallMemo]) -> None: - if hasattr(expected_type, '__union_params__'): - # Python 3.5 - union_params = expected_type.__union_params__ - else: - # Python 3.6+ - union_params = expected_type.__args__ - - for type_ in union_params: - try: - check_type(argname, value, type_, memo) - return - except TypeError: - pass - - typelist = ', '.join(get_type_name(t) for t in union_params) - raise TypeError('type of {} must be one of ({}); got {} instead'. - format(argname, typelist, qualified_name(value))) - - -def check_class(argname: str, value, expected_type, memo: Optional[_CallMemo]) -> None: - if not isclass(value): - raise TypeError('type of {} must be a type; got {} instead'.format( - argname, qualified_name(value))) - - # Needed on Python 3.7+ - if expected_type is Type: - return - - expected_class = expected_type.__args__[0] if expected_type.__args__ else None - if expected_class: - if isinstance(expected_class, TypeVar): - check_typevar(argname, value, expected_class, memo, True) - elif not issubclass(value, expected_class): - raise TypeError('{} must be a subclass of {}; got {} instead'.format( - argname, qualified_name(expected_class), qualified_name(value))) - - -def check_typevar(argname: str, value, typevar: TypeVar, memo: Optional[_CallMemo], - subclass_check: bool = False) -> None: - if memo is None: - raise TypeError('encountered a TypeVar but a call memo was not provided') - - bound_type = memo.typevars.get(typevar, typevar.__bound__) - value_type = value if subclass_check else type(value) - subject = argname if subclass_check else 'type of ' + argname - if bound_type is None: - # The type variable hasn't been bound yet -- check that the given value matches the - # constraints of the type variable, if any - if typevar.__constraints__ and value_type not in typevar.__constraints__: - typelist = ', '.join(get_type_name(t) for t in typevar.__constraints__ - if t is not object) - raise TypeError('{} must be one of ({}); got {} instead'. - format(subject, typelist, qualified_name(value_type))) - elif typevar.__covariant__ or typevar.__bound__: - if not issubclass(value_type, bound_type): - raise TypeError( - '{} must be {} or one of its subclasses; got {} instead'. - format(subject, qualified_name(bound_type), qualified_name(value_type))) - elif typevar.__contravariant__: - if not issubclass(bound_type, value_type): - raise TypeError( - '{} must be {} or one of its superclasses; got {} instead'. - format(subject, qualified_name(bound_type), qualified_name(value_type))) - else: # invariant - if value_type is not bound_type: - raise TypeError( - '{} must be exactly {}; got {} instead'. - format(subject, qualified_name(bound_type), qualified_name(value_type))) - - if typevar not in memo.typevars: - # Bind the type variable to a concrete type - memo.typevars[typevar] = value_type - - -def check_number(argname: str, value, expected_type): - if expected_type is complex and not isinstance(value, (complex, float, int)): - raise TypeError('type of {} must be either complex, float or int; got {} instead'. - format(argname, qualified_name(value.__class__))) - elif expected_type is float and not isinstance(value, (float, int)): - raise TypeError('type of {} must be either float or int; got {} instead'. - format(argname, qualified_name(value.__class__))) - - -# Equality checks are applied to these -origin_type_checkers = { - Callable: check_callable, - collections.abc.Callable: check_callable, - dict: check_dict, - Dict: check_dict, - list: check_list, - List: check_list, - Sequence: check_sequence, - collections.abc.Sequence: check_sequence, - set: check_set, - Set: check_set, - tuple: check_tuple, - Tuple: check_tuple, - type: check_class, - Union: check_union -} -_subclass_check_unions = hasattr(Union, '__union_set_params__') -if Type is not None: - origin_type_checkers[Type] = check_class - - -def check_type(argname: str, value, expected_type, memo: Optional[_CallMemo] = None) -> None: - """ - Ensure that ``value`` matches ``expected_type``. - - The types from the :mod:`typing` module do not support :func:`isinstance` or :func:`issubclass` - so a number of type specific checks are required. This function knows which checker to call - for which type. - - :param argname: name of the argument to check; used for error messages - :param value: value to be checked against ``expected_type`` - :param expected_type: a class or generic type instance - - """ - if expected_type is Any: - return - - if expected_type is None: - # Only happens on < 3.6 - expected_type = type(None) - - origin_type = getattr(expected_type, '__origin__', None) - if origin_type is not None: - checker_func = origin_type_checkers.get(origin_type) - if checker_func: - checker_func(argname, value, expected_type, memo) - else: - check_type(argname, value, origin_type, memo) - elif isclass(expected_type): - if issubclass(expected_type, Tuple): - check_tuple(argname, value, expected_type, memo) - elif issubclass(expected_type, Callable) and hasattr(expected_type, '__args__'): - # Needed on Python 3.5.0 to 3.5.2 - check_callable(argname, value, expected_type, memo) - elif issubclass(expected_type, (float, complex)): - check_number(argname, value, expected_type) - elif _subclass_check_unions and issubclass(expected_type, Union): - check_union(argname, value, expected_type, memo) - elif isinstance(expected_type, TypeVar): - check_typevar(argname, value, expected_type, memo) - else: - expected_type = (getattr(expected_type, '__extra__', None) or origin_type or - expected_type) - if not isinstance(value, expected_type): - raise TypeError( - 'type of {} must be {}; got {} instead'. - format(argname, qualified_name(expected_type), qualified_name(value))) - elif isinstance(expected_type, TypeVar): - # Only happens on < 3.6 - check_typevar(argname, value, expected_type, memo) - elif (isfunction(expected_type) and - getattr(expected_type, "__module__", None) == "typing" and - getattr(expected_type, "__qualname__", None).startswith("NewType.") and - hasattr(expected_type, "__supertype__")): - # typing.NewType, should check against supertype (recursively) - return check_type(argname, value, expected_type.__supertype__, memo) - - -def check_return_type(retval, memo: Optional[_CallMemo]) -> bool: - if 'return' in memo.type_hints: - try: - check_type('the return value', retval, memo.type_hints['return'], memo) - except TypeError as exc: # suppress unnecessarily long tracebacks - raise TypeError(exc) from None - - return True - - -def check_argument_types(memo: Optional[_CallMemo] = None) -> bool: - """ - Check that the argument values match the annotated types. - - Unless both ``args`` and ``kwargs`` are provided, the information will be retrieved from - the previous stack frame (ie. from the function that called this). - - :return: ``True`` - :raises TypeError: if there is an argument type mismatch - - """ - if memo is None: - frame = inspect.currentframe().f_back - try: - func = find_function(frame) - except LookupError: - return True # This can happen with the Pydev/PyCharm debugger extension installed - - memo = _CallMemo(func, frame) - - for argname, expected_type in memo.type_hints.items(): - if argname != 'return' and argname in memo.arguments: - value = memo.arguments[argname] - description = 'argument "{}"'.format(argname) - try: - check_type(description, value, expected_type, memo) - except TypeError as exc: # suppress unnecessarily long tracebacks - raise TypeError(exc) from None - - return True - - -def typechecked(func: Callable = None, *, always: bool = False): - """ - Perform runtime type checking on the arguments that are passed to the wrapped function. - - The return value is also checked against the return annotation if any. - - If the ``__debug__`` global variable is set to ``False``, no wrapping and therefore no type - checking is done, unless ``always`` is ``True``. - - :param func: the function to enable type checking for - :param always: ``True`` to enable type checks even in optimized mode - - """ - if not __debug__ and not always: # pragma: no cover - return func - - if func is None: - return partial(typechecked, always=always) - - if not getattr(func, '__annotations__', None): - warn('no type annotations present -- not typechecking {}'.format(function_name(func))) - return func - - @wraps(func) - def wrapper(*args, **kwargs): - memo = _CallMemo(func, args=args, kwargs=kwargs) - check_argument_types(memo) - retval = func(*args, **kwargs) - check_return_type(retval, memo) - return retval - - return wrapper - - -class TypeWarning(UserWarning): - """ - A warning that is emitted when a type check fails. - - :ivar str event: ``call`` or ``return`` - :ivar Callable func: the function in which the violation occurred (the called function if event - is ``call``, or the function where a value of the wrong type was returned from if event is - ``return``) - :ivar str error: the error message contained by the caught :cls:`TypeError` - :ivar frame: the frame in which the violation occurred - """ - - __slots__ = ('func', 'event', 'message', 'frame') - - def __init__(self, memo: Optional[_CallMemo], event: str, frame, - exception: TypeError): # pragma: no cover - self.func = memo.func - self.event = event - self.error = str(exception) - self.frame = frame - - if self.event == 'call': - caller_frame = self.frame.f_back - event = 'call to {}() from {}:{}'.format( - function_name(self.func), caller_frame.f_code.co_filename, caller_frame.f_lineno) - else: - event = 'return from {}() at {}:{}'.format( - function_name(self.func), self.frame.f_code.co_filename, self.frame.f_lineno) - - super().__init__('[{thread_name}] {event}: {self.error}'.format( - thread_name=threading.current_thread().name, event=event, self=self)) - - @property - def stack(self): - """Return the stack where the last frame is from the target function.""" - return extract_stack(self.frame) - - def print_stack(self, file: TextIO = None, limit: int = None) -> None: - """ - Print the traceback from the stack frame where the target function was run. - - :param file: an open file to print to (prints to stdout if omitted) - :param limit: the maximum number of stack frames to print - - """ - print_stack(self.frame, limit, file) - - -class TypeChecker: - """ - A type checker that collects type violations by hooking into ``sys.setprofile()``. - - :param all_threads: ``True`` to check types in all threads created while the checker is - running, ``False`` to only check in the current one - """ - - def __init__(self, packages: Union[str, Sequence[str]], *, all_threads: bool = True): - assert check_argument_types() - self.all_threads = all_threads - self._call_memos = {} # type: Dict[Any, _CallMemo] - self._previous_profiler = None - self._previous_thread_profiler = None - self._active = False - - if isinstance(packages, str): - self._packages = (packages,) - else: - self._packages = tuple(packages) - - @property - def active(self) -> bool: - """Return ``True`` if currently collecting type violations.""" - return self._active - - def should_check_type(self, func: Callable) -> bool: - if not func.__annotations__: - # No point in checking if there are no type hints - return False - else: - # Check types if the module matches any of the package prefixes - return any(func.__module__ == package or func.__module__.startswith(package + '.') - for package in self._packages) - - def start(self): - if self._active: - raise RuntimeError('type checker already running') - - self._active = True - - # Install this instance as the current profiler - self._previous_profiler = sys.getprofile() - sys.setprofile(self) - - # If requested, set this instance as the default profiler for all future threads - # (does not affect existing threads) - if self.all_threads: - self._previous_thread_profiler = threading._profile_hook - threading.setprofile(self) - - def stop(self): - if self._active: - if sys.getprofile() is self: - sys.setprofile(self._previous_profiler) - else: # pragma: no cover - warn('the system profiling hook has changed unexpectedly') - - if self.all_threads: - if threading._profile_hook is self: - threading.setprofile(self._previous_thread_profiler) - else: # pragma: no cover - warn('the threading profiling hook has changed unexpectedly') - - self._active = False - - def __enter__(self): - self.start() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.stop() - - def __call__(self, frame, event: str, arg) -> None: # pragma: no cover - if not self._active: - # This happens if all_threads was enabled and a thread was created when the checker was - # running but was then stopped. The thread's profiler callback can't be reset any other - # way but this. - sys.setprofile(self._previous_thread_profiler) - return - - # If an actual profiler is running, don't include the type checking times in its results - if event == 'call': - try: - func = find_function(frame) - except Exception: - func = None - - if func is not None and self.should_check_type(func): - memo = self._call_memos[frame] = _CallMemo(func, frame) - try: - check_argument_types(memo) - except TypeError as exc: - warn(TypeWarning(memo, event, frame, exc)) - - if self._previous_profiler is not None: - self._previous_profiler(frame, event, arg) - elif event == 'return': - if self._previous_profiler is not None: - self._previous_profiler(frame, event, arg) - - memo = self._call_memos.pop(frame, None) - if memo is not None: - try: - check_return_type(arg, memo) - except TypeError as exc: - warn(TypeWarning(memo, event, frame, exc)) - elif self._previous_profiler is not None: - self._previous_profiler(frame, event, arg)