pax_global_header00006660000000000000000000000064146023206130014507gustar00rootroot0000000000000052 comment=1f5c62123c16c85f6f68cd02b8149f0605688ca6 typeddjango-pytest-mypy-plugins-be9eb51/000077500000000000000000000000001460232061300205165ustar00rootroot00000000000000typeddjango-pytest-mypy-plugins-be9eb51/.editorconfig000066400000000000000000000004521460232061300231740ustar00rootroot00000000000000# Check http://editorconfig.org for more information # This is the main config file for this project: root = true [*] charset = utf-8 trim_trailing_whitespace = true end_of_line = lf indent_style = space insert_final_newline = true indent_size = 2 [*.py] indent_size = 4 [*.pyi] indent_size = 4 typeddjango-pytest-mypy-plugins-be9eb51/.github/000077500000000000000000000000001460232061300220565ustar00rootroot00000000000000typeddjango-pytest-mypy-plugins-be9eb51/.github/dependabot.yml000066400000000000000000000004261460232061300247100ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily time: "02:00" open-pull-requests-limit: 10 - package-ecosystem: github-actions directory: "/" schedule: interval: daily time: "02:00" open-pull-requests-limit: 10 typeddjango-pytest-mypy-plugins-be9eb51/.github/workflows/000077500000000000000000000000001460232061300241135ustar00rootroot00000000000000typeddjango-pytest-mypy-plugins-be9eb51/.github/workflows/test.yml000066400000000000000000000027611460232061300256230ustar00rootroot00000000000000name: test on: push: branches: - master pull_request: workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] pytest-version: ["~=7.2", "~=8.1"] # TODO: remove after several new versions of mypy mypy-version: ["~=1.7", "~=1.9"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | pip install -U pip setuptools wheel pip install -e . # Force correct `pytest` version for different envs: pip install -U "pytest${{ matrix.pytest-version }}" # Force correct `mypy` version: pip install -U "mypy${{ matrix.mypy-version }}" - name: Run tests run: pytest lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: 3.12 - name: Install dependencies run: | pip install -U pip setuptools wheel pip install -r requirements.txt - name: Run linters run: | mypy . black --check pytest_mypy_plugins setup.py isort --check --diff . typeddjango-pytest-mypy-plugins-be9eb51/.gitignore000066400000000000000000000063151460232061300225130ustar00rootroot00000000000000.idea/ ## Mostly complete version from https://github.com/github/gitignore/blob/e5323759e387ba347a9d50f8b0ddd16502eb71d4/Python.gitignore # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ cache/* # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ # VS code .vscode/launch.json typeddjango-pytest-mypy-plugins-be9eb51/CHANGELOG.md000066400000000000000000000053021460232061300223270ustar00rootroot00000000000000# Version history ## 3.1.2 ### Bugfixes - Fix joining `toml` configs with `[[mypy.overrides]]` ## 3.1.1 ### Bugfixes - Make sure that schema is open by default: only check existing fields - Add `--mypy-schema-closed` option to check schemas with no extra fields ## 3.1.0 ### Features - Add `python3.12` support - Add `mypy@1.8.0` support - Add schema definition ## 3.0.0 ### Features - *Breaking*: Drop python3.7 support - Add `pyproject.toml` config file support with `--mypy-pyproject-toml-file` option ### Bugfixes - Add `tox.ini` file to `sdist` package - Add `requirements.txt` file to `sdist` package - Add `pyproject.toml` file to `sdist` package ## 2.0.0 ### Features - Use `jinja2` instead of `chevron` for templating - Allow parametrizing `mypy_config` field in tests - Bump minimal `mypy` and `pytest` versions ### Bugfixes - Also include `mypy.ini` and `pytest.ini` to `sdist` package ## Version 1.11.1 ### Bugfixes - Adds `tests/` subfolder to `sdist` package ## Version 1.11.0 ### Features - Adds `python3.11` support and promise about `python3.12` support - Removes `pkg_resources` to use `packaging` instead ## Version 1.10.1 ### Bugfixes - Removes unused depenencies for `python < 3.7` - Fixes compatibility with pytest 7.2, broken due to a private import from `py._path`. ## Version 1.10.0 ### Features - Changes how `mypy>=0.970` handles `MYPYPATH` - Bumps minimal `mypy` version to `mypy>=0.970` - Drops `python3.6` support ## Version 1.9.3 ### Bugfixes - Fixes `DeprecationWarning` for using `py.LocalPath` for `pytest>=7.0` #89 ## Version 1.9.2 ### Bugfixes - Removes usages of `distutils` #71 - Fixes multiline messages #66 - Fixes that empty output test cases was almost ignored #63 - Fixes output formatting for expected messages #66 ## Version 1.9.1 ## Bugfixes - Fixes that `regex` and `dataclasses` dependencies were not listed in `setup.py` ## Version 1.9.0 ## Features - Adds `regex` support in matching test output - Adds a flag for expected failures - Replaces deprecated `pystache` with `chevron` ## Misc - Updates `mypy` ## Version 1.8.0 We missed this released by mistake. ## Version 1.7.0 ### Features - Adds `--mypy-only-local-stub` CLI flag to ignore errors in site-packages ## Version 1.6.1 ### Bugfixes - Changes how `MYPYPATH` and `PYTHONPATH` are calcualted. We now expand `$PWD` variable and also include relative paths specified in `env:` section ## Version 1.6.0 ### Features - Adds `python3.9` support - Bumps required version of `pytest` to `>=6.0` - Bumps required version of `mypy` to `>=0.790` ### Misc - Moves from Travis to Github Actions ## Version 1.5.0 ### Features - Adds `PYTHONPATH` and `MYPYPATH` special handling typeddjango-pytest-mypy-plugins-be9eb51/LICENSE000066400000000000000000000020361460232061300215240ustar00rootroot00000000000000Copyright 2018 Maksim Kurnikov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.typeddjango-pytest-mypy-plugins-be9eb51/MANIFEST.in000066400000000000000000000001401460232061300222470ustar00rootroot00000000000000include requirements.txt include tox.ini include pyproject.toml graft pytest_mypy_plugins/tests typeddjango-pytest-mypy-plugins-be9eb51/README.md000066400000000000000000000207771460232061300220120ustar00rootroot00000000000000mypy logo # pytest plugin for testing mypy types, stubs, and plugins [![Tests Status](https://github.com/typeddjango/pytest-mypy-plugins/actions/workflows/test.yml/badge.svg)](https://github.com/typeddjango/pytest-mypy-plugins/actions/workflows/test.yml) [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) [![Gitter](https://badges.gitter.im/mypy-django/Lobby.svg)](https://gitter.im/mypy-django/Lobby) [![PyPI](https://img.shields.io/pypi/v/pytest-mypy-plugins?color=blue)](https://pypi.org/project/pytest-mypy-plugins/) [![Conda Version](https://img.shields.io/conda/vn/conda-forge/pytest-mypy-plugins.svg?color=blue)](https://anaconda.org/conda-forge/pytest-mypy-plugins) ## Installation This package is available on [PyPI](https://pypi.org/project/pytest-mypy-plugins/) ```bash pip install pytest-mypy-plugins ``` and [conda-forge](https://anaconda.org/conda-forge/pytest-mypy-plugins) ```bash conda install -c conda-forge pytest-mypy-plugins ``` ## Usage ### Running Plugin, after installation, is automatically picked up by `pytest` therefore it is sufficient to just execute: ```bash pytest ``` ### Paths The `PYTHONPATH` and `MYPYPATH` environment variables, if set, are passed to `mypy` on invocation. This may be helpful if you are testing a local plugin and need to provide an import path to it. Be aware that when `mypy` is run in a subprocess (the default) the test cases are run in temporary working directories where relative paths such as `PYTHONPATH=./my_plugin` do not reference the directory which you are running `pytest` from. If you encounter this, consider invoking `pytest` with `--mypy-same-process` or make your paths absolute, e.g. `PYTHONPATH=$(pwd)/my_plugin pytest`. You can also specify `PYTHONPATH`, `MYPYPATH`, or any other environment variable in `env:` section of `yml` spec: ```yml - case: mypy_path_from_env main: | from pair import Pair instance: Pair reveal_type(instance) # N: Revealed type is 'pair.Pair' env: - MYPYPATH=../fixtures ``` ### What is a test case? In general each test case is just an element in an array written in a properly formatted `YAML` file. On top of that, each case must comply to following types: | Property | Type | Description | | --------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | | `case` | `str` | Name of the test case, complies to `[a-zA-Z0-9]` pattern | | `main` | `str` | Portion of the code as if written in `.py` file | | `files` | `Optional[List[File]]=[]`\* | List of extra files to simulate imports if needed | | `disable_cache` | `Optional[bool]=False` | Set to `true` disables `mypy` caching | | `mypy_config` | `Optional[str]` | Inline `mypy` configuration, passed directly to `mypy` as `--config-file` option, possibly joined with `--mypy-pyproject-toml-file` or `--mypy-ini-file` contents if they are passed. By default is treated as `ini`, treated as `toml` only if `--mypy-pyproject-toml-file` is passed | | `env` | `Optional[Dict[str, str]]={}` | Environmental variables to be provided inside of test run | | `parametrized` | `Optional[List[Parameter]]=[]`\* | List of parameters, similar to [`@pytest.mark.parametrize`](https://docs.pytest.org/en/stable/parametrize.html) | | `skip` | `str` | Expression evaluated with following globals set: `sys`, `os`, `pytest` and `platform` | | `expect_fail` | `bool` | Mark test case as an expected failure, like [`@pytest.mark.xfail`](https://docs.pytest.org/en/stable/skipping.html) | | `regex` | `str` | Allow regular expressions in comments to be matched against actual output. Defaults to "no", i.e. matches full text.| (*) Appendix to **pseudo** types used above: ```python class File: path: str content: Optional[str] = None Parameter = Mapping[str, Any] ``` Implementation notes: - `main` must be non-empty string that evaluates to valid **Python** code, - `content` of each of extra files must evaluate to valid **Python** code, - `parametrized` entries must all be the objects of the same _type_. It simply means that each entry must have **exact** same set of keys, - `skip` - an expression set in `skip` is passed directly into [`eval`](https://docs.python.org/3/library/functions.html#eval). It is advised to take a peek and learn about how `eval` works. Repository also offers a [JSONSchema](pytest_mypy_plugins/schema.json), with which it validates the input. It can also offer your editor auto-completions, descriptions, and validation. All you have to do, add the following line at the top of your YAML file: ```yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/typeddjango/pytest-mypy-plugins/master/pytest_mypy_plugins/schema.json ``` ### Example #### 1. Inline type expectations ```yaml # typesafety/test_request.yml - case: request_object_has_user_of_type_auth_user_model main: | from django.http.request import HttpRequest reveal_type(HttpRequest().user) # N: Revealed type is 'myapp.models.MyUser' # check that other fields work ok reveal_type(HttpRequest().method) # N: Revealed type is 'Union[builtins.str, None]' files: - path: myapp/__init__.py - path: myapp/models.py content: | from django.db import models class MyUser(models.Model): pass ``` #### 2. `@parametrized` ```yaml - case: with_params parametrized: - val: 1 rt: builtins.int - val: 1.0 rt: builtins.float main: | reveal_type({{ val }}) # N: Revealed type is '{{ rt }}' ``` Properties that you can parametrize: - `main` - `mypy_config` - `out` #### 3. Longer type expectations ```yaml - case: with_out main: | reveal_type('abc') out: | main:1: note: Revealed type is 'builtins.str' ``` #### 4. Regular expressions in expectations ```yaml - case: expected_message_regex_with_out regex: yes main: | a = 'abc' reveal_type(a) out: | main:2: note: .*str.* ``` #### 5. Regular expressions specific lines of output. ```yaml - case: expected_single_message_regex main: | a = 'hello' reveal_type(a) # NR: .*str.* ``` ## Options ``` mypy-tests: --mypy-testing-base=MYPY_TESTING_BASE Base directory for tests to use --mypy-pyproject-toml-file=MYPY_PYPROJECT_TOML_FILE Which `pyproject.toml` file to use as a default config for tests. Incompatible with `--mypy-ini-file` --mypy-ini-file=MYPY_INI_FILE Which `.ini` file to use as a default config for tests. Incompatible with `--mypy-pyproject-toml-file` --mypy-same-process Run in the same process. Useful for debugging, will create problems with import cache --mypy-extension-hook=MYPY_EXTENSION_HOOK Fully qualified path to the extension hook function, in case you need custom yaml keys. Has to be top-level --mypy-only-local-stub mypy will ignore errors from site-packages --mypy-closed-schema Use closed schema to validate YAML test cases, which won't allow any extra keys (does not work well with `--mypy-extension-hook`) ``` ## Further reading - [Testing mypy stubs, plugins, and types](https://sobolevn.me/2019/08/testing-mypy-types) ## License [MIT](https://github.com/typeddjango/pytest-mypy-plugins/blob/master/LICENSE) typeddjango-pytest-mypy-plugins-be9eb51/pyproject.toml000066400000000000000000000011071460232061300234310ustar00rootroot00000000000000[tool.mypy] ignore_missing_imports = true strict_optional = true no_implicit_optional = true disallow_any_generics = true disallow_untyped_defs = true strict_equality = true warn_unreachable = true warn_no_return = true warn_unused_ignores = true warn_redundant_casts = true warn_unused_configs = true [tool.pytest.ini_options] python_files = "test_*.py" addopts = "-s --mypy-extension-hook pytest_mypy_plugins.tests.reveal_type_hook.hook" [tool.black] line-length = 120 target-version = ["py38"] [tool.isort] include_trailing_comma = true multi_line_output = 3 profile = "black" typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/000077500000000000000000000000001460232061300246655ustar00rootroot00000000000000typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/__init__.py000066400000000000000000000000001460232061300267640ustar00rootroot00000000000000typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/collect.py000066400000000000000000000205741460232061300266740ustar00rootroot00000000000000import json import os import pathlib import platform import sys import tempfile from dataclasses import dataclass from typing import ( TYPE_CHECKING, Any, Dict, Hashable, Iterator, List, Mapping, Optional, Set, ) import jsonschema import py.path import pytest import yaml from _pytest.config.argparsing import Parser from _pytest.nodes import Node from packaging.version import Version from pytest_mypy_plugins import utils if TYPE_CHECKING: from pytest_mypy_plugins.item import YamlTestItem @dataclass class File: path: str content: str def validate_schema(data: Any, *, is_closed: bool = False) -> None: """Validate the schema of the file-under-test.""" # Unfortunately, yaml.safe_load() returns Any, # so we make our intention explicit here. if not isinstance(data, list): raise TypeError(f"Test file has to be YAML list, got {type(data)!r}.") schema = json.loads((pathlib.Path(__file__).parent / "schema.json").read_text("utf8")) schema["items"]["properties"]["__line__"] = { "type": "integer", "description": "Line number where the test starts (`pytest-mypy-plugins` internal)", } schema["items"]["additionalProperties"] = not is_closed jsonschema.validate(instance=data, schema=schema) def parse_test_files(test_files: List[Dict[str, Any]]) -> List[File]: files: List[File] = [] for test_file in test_files: path = test_file.get("path", "main.py") file = File(path=path, content=test_file.get("content", "")) files.append(file) return files def parse_environment_variables(env_vars: List[str]) -> Dict[str, str]: parsed_vars: Dict[str, str] = {} for env_var in env_vars: name, _, value = env_var.partition("=") parsed_vars[name] = value return parsed_vars def parse_parametrized(params: List[Mapping[str, Any]]) -> List[Mapping[str, Any]]: if not params: return [{}] parsed_params: List[Mapping[str, Any]] = [] known_params: Optional[Set[str]] = None for idx, param in enumerate(params): param_keys = set(sorted(param.keys())) if not known_params: known_params = param_keys elif known_params.intersection(param_keys) != known_params: raise ValueError( "All parametrized entries must have same keys." f'First entry is {", ".join(known_params)} but {", ".join(param_keys)} ' "was spotted at {idx} position", ) parsed_params.append({k: v for k, v in param.items() if not k.startswith("__")}) return parsed_params class SafeLineLoader(yaml.SafeLoader): def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> Dict[Hashable, Any]: mapping = super().construct_mapping(node, deep=deep) # Add 1 so line numbering starts at 1 starting_line = node.start_mark.line + 1 for title_node, _contents_node in node.value: if title_node.value == "main": starting_line = title_node.start_mark.line + 1 mapping["__line__"] = starting_line return mapping class YamlTestFile(pytest.File): def collect(self) -> Iterator["YamlTestItem"]: from pytest_mypy_plugins.item import YamlTestItem # To support both Pytest 6.x and 7.x path = getattr(self, "path", None) or getattr(self, "fspath") parsed_file = yaml.load(stream=path.read_text("utf8"), Loader=SafeLineLoader) if parsed_file is None: return validate_schema(parsed_file, is_closed=self.config.option.mypy_closed_schema) if not isinstance(parsed_file, list): raise ValueError(f"Test file has to be YAML list, got {type(parsed_file)!r}.") for raw_test in parsed_file: test_name_prefix = raw_test["case"] if " " in test_name_prefix: raise ValueError(f"Invalid test name {test_name_prefix!r}, only '[a-zA-Z0-9_]' is allowed.") else: parametrized = parse_parametrized(raw_test.get("parametrized", [])) for params in parametrized: if params: test_name_suffix = ",".join(f"{k}={v}" for k, v in params.items()) test_name_suffix = f"[{test_name_suffix}]" else: test_name_suffix = "" test_name = f"{test_name_prefix}{test_name_suffix}" main_content = utils.render_template(template=raw_test["main"], data=params) main_file = File(path="main.py", content=main_content) test_files = [main_file] + parse_test_files(raw_test.get("files", [])) expect_fail = raw_test.get("expect_fail", False) regex = raw_test.get("regex", False) expected_output = [] for test_file in test_files: output_lines = utils.extract_output_matchers_from_comments( test_file.path, test_file.content.split("\n"), regex=regex ) expected_output.extend(output_lines) starting_lineno = raw_test["__line__"] extra_environment_variables = parse_environment_variables(raw_test.get("env", [])) disable_cache = raw_test.get("disable_cache", False) expected_output.extend( utils.extract_output_matchers_from_out(raw_test.get("out", ""), params, regex=regex) ) additional_mypy_config = utils.render_template(template=raw_test.get("mypy_config", ""), data=params) skip = self._eval_skip(str(raw_test.get("skip", "False"))) if not skip: yield YamlTestItem.from_parent( self, name=test_name, files=test_files, starting_lineno=starting_lineno, environment_variables=extra_environment_variables, disable_cache=disable_cache, expected_output=expected_output, parsed_test_data=raw_test, mypy_config=additional_mypy_config, expect_fail=expect_fail, ) def _eval_skip(self, skip_if: str) -> bool: return eval(skip_if, {"sys": sys, "os": os, "pytest": pytest, "platform": platform}) if Version(pytest.__version__) >= Version("7.0.0rc1"): def pytest_collect_file(file_path: pathlib.Path, parent: Node) -> Optional[YamlTestFile]: if file_path.suffix in {".yaml", ".yml"} and file_path.name.startswith(("test-", "test_")): return YamlTestFile.from_parent(parent, path=file_path, fspath=None) return None else: def pytest_collect_file(path: py.path.local, parent: Node) -> Optional[YamlTestFile]: # type: ignore[misc] if path.ext in {".yaml", ".yml"} and path.basename.startswith(("test-", "test_")): return YamlTestFile.from_parent(parent, fspath=path) return None def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("mypy-tests") group.addoption( "--mypy-testing-base", type=str, default=tempfile.gettempdir(), help="Base directory for tests to use" ) group.addoption( "--mypy-pyproject-toml-file", type=str, help="Which `pyproject.toml` file to use as a default config for tests. Incompatible with `--mypy-ini-file`", ) group.addoption( "--mypy-ini-file", type=str, help="Which `.ini` file to use as a default config for tests. Incompatible with `--mypy-pyproject-toml-file`", ) group.addoption( "--mypy-same-process", action="store_true", help="Run in the same process. Useful for debugging, will create problems with import cache", ) group.addoption( "--mypy-extension-hook", type=str, help="Fully qualified path to the extension hook function, in case you need custom yaml keys. " "Has to be top-level.", ) group.addoption( "--mypy-only-local-stub", action="store_true", help="mypy will ignore errors from site-packages", ) group.addoption( "--mypy-closed-schema", action="store_true", help="Use closed schema to validate YAML test cases, which won't allow any extra keys (does not work well with `--mypy-extension-hook`)", ) typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/configs.py000066400000000000000000000047701460232061300266770ustar00rootroot00000000000000from configparser import ConfigParser from pathlib import Path from textwrap import dedent from typing import Final, Optional import tomlkit _TOML_TABLE_NAME: Final = "[tool.mypy]" def join_ini_configs(base_ini_fpath: Optional[str], additional_mypy_config: str, execution_path: Path) -> Optional[str]: mypy_ini_config = ConfigParser() if base_ini_fpath: mypy_ini_config.read(base_ini_fpath) if additional_mypy_config: if "[mypy]" not in additional_mypy_config: additional_mypy_config = f"[mypy]\n{additional_mypy_config}" mypy_ini_config.read_string(additional_mypy_config) if mypy_ini_config.sections(): mypy_config_file_path = execution_path / "mypy.ini" with mypy_config_file_path.open("w") as f: mypy_ini_config.write(f) return str(mypy_config_file_path) return None def join_toml_configs( base_pyproject_toml_fpath: str, additional_mypy_config: str, execution_path: Path ) -> Optional[str]: if base_pyproject_toml_fpath: with open(base_pyproject_toml_fpath) as f: toml_config = tomlkit.parse(f.read()) else: # Emtpy document with `[tool.mypy` empty table, # useful for overrides further. toml_config = tomlkit.document() if "tool" not in toml_config or "mypy" not in toml_config["tool"]: # type: ignore[operator] tool = tomlkit.table(is_super_table=True) tool.append("mypy", tomlkit.table()) toml_config.append("tool", tool) if additional_mypy_config: if _TOML_TABLE_NAME not in additional_mypy_config: additional_mypy_config = f"{_TOML_TABLE_NAME}\n{dedent(additional_mypy_config)}" additional_data = tomlkit.parse(additional_mypy_config) toml_config["tool"]["mypy"].update( # type: ignore[index, union-attr] additional_data["tool"]["mypy"].value.items(), # type: ignore[index] ) mypy_config_file_path = execution_path / "pyproject.toml" with mypy_config_file_path.open("w") as f: # We don't want the whole config file, because it can contain # other sections like `[tool.isort]`, we only need `[tool.mypy]` part. tool_mypy = toml_config["tool"]["mypy"] # type: ignore[index] # construct toml output min_toml = tomlkit.document() min_tool = tomlkit.table(is_super_table=True) min_toml.append("tool", min_tool) min_tool.append("mypy", tool_mypy) f.write(min_toml.as_string()) return str(mypy_config_file_path) typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/item.py000066400000000000000000000365011460232061300262020ustar00rootroot00000000000000import importlib import io import os import subprocess import sys import tempfile from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, TextIO, Tuple, Union import py import pytest from _pytest._code import ExceptionInfo from _pytest._code.code import ReprEntry, ReprFileLocation, TerminalRepr from _pytest._io import TerminalWriter from _pytest.config import Config from mypy import build from mypy.fscache import FileSystemCache from mypy.main import process_options if TYPE_CHECKING: from _pytest._code.code import _TracebackStyle from pytest_mypy_plugins import configs, utils from pytest_mypy_plugins.collect import File, YamlTestFile from pytest_mypy_plugins.utils import ( OutputMatcher, TypecheckAssertionError, assert_expected_matched_actual, fname_to_module, ) class TraceLastReprEntry(ReprEntry): def toterminal(self, tw: TerminalWriter) -> None: if not self.reprfileloc: return self.reprfileloc.toterminal(tw) for line in self.lines: red = line.startswith("E ") tw.line(line, bold=True, red=red) return def make_files(rootdir: Path, files_to_create: Dict[str, str]) -> List[str]: created_modules = [] for rel_fpath, file_contents in files_to_create.items(): fpath = rootdir / rel_fpath fpath.parent.mkdir(parents=True, exist_ok=True) fpath.write_text(file_contents) created_module = fname_to_module(fpath, root_path=rootdir) if created_module: created_modules.append(created_module) return created_modules def replace_fpath_with_module_name(line: str, rootdir: Path) -> str: if ":" not in line: return line out_fpath, res_line = line.split(":", 1) line = os.path.relpath(out_fpath, start=rootdir) + ":" + res_line return line.strip().replace(".py:", ":") def maybe_to_abspath(rel_or_abs: str, rootdir: Optional[Path]) -> str: rel_or_abs = os.path.expandvars(rel_or_abs) if rootdir is None or os.path.isabs(rel_or_abs): return rel_or_abs return str(rootdir / rel_or_abs) class ReturnCodes: SUCCESS = 0 FAIL = 1 FATAL_ERROR = 2 def run_mypy_typechecking(cmd_options: List[str], stdout: TextIO, stderr: TextIO) -> Optional[Union[str, int]]: fscache = FileSystemCache() sources, options = process_options(cmd_options, fscache=fscache) error_messages = [] # Different mypy versions have different arity of `flush_errors`: 2 and 3 params def flush_errors(*args: Any) -> None: new_messages: List[str] serious: bool *_, new_messages, serious = args error_messages.extend(new_messages) f = stderr if serious else stdout try: for msg in new_messages: f.write(msg + "\n") f.flush() except BrokenPipeError: sys.exit(ReturnCodes.FATAL_ERROR) try: build.build(sources, options, flush_errors=flush_errors, fscache=fscache, stdout=stdout, stderr=stderr) except SystemExit as sysexit: return sysexit.code finally: fscache.flush() if error_messages: return ReturnCodes.FAIL return ReturnCodes.SUCCESS class YamlTestItem(pytest.Item): def __init__( self, name: str, parent: Optional[YamlTestFile] = None, config: Optional[Config] = None, *, files: List[File], starting_lineno: int, expected_output: List[OutputMatcher], environment_variables: Dict[str, Any], disable_cache: bool, mypy_config: str, parsed_test_data: Dict[str, Any], expect_fail: bool, ) -> None: super().__init__(name, parent, config) self.files = files self.environment_variables = environment_variables self.disable_cache = disable_cache self.expect_fail = expect_fail self.expected_output = expected_output self.starting_lineno = starting_lineno self.additional_mypy_config = mypy_config self.parsed_test_data = parsed_test_data self.same_process = self.config.option.mypy_same_process self.test_only_local_stub = self.config.option.mypy_only_local_stub # config parameters self.root_directory = self.config.option.mypy_testing_base # You cannot use both `.ini` and `pyproject.toml` files at the same time: if self.config.option.mypy_ini_file and self.config.option.mypy_pyproject_toml_file: raise ValueError("Cannot specify both `--mypy-ini-file` and `--mypy-pyproject-toml-file`") if self.config.option.mypy_ini_file: self.base_ini_fpath = os.path.abspath(self.config.option.mypy_ini_file) else: self.base_ini_fpath = None if self.config.option.mypy_pyproject_toml_file: self.base_pyproject_toml_fpath = os.path.abspath(self.config.option.mypy_pyproject_toml_file) else: self.base_pyproject_toml_fpath = None self.incremental_cache_dir = os.path.join(self.root_directory, ".mypy_cache") def make_test_file(self, file: File) -> None: current_directory = Path.cwd() fpath = current_directory / file.path fpath.parent.mkdir(parents=True, exist_ok=True) fpath.write_text(file.content) def make_test_files_in_current_directory(self) -> None: for file in self.files: self.make_test_file(file) def remove_cache_files(self, fpath_no_suffix: Path) -> None: cache_file = Path(self.incremental_cache_dir) cache_file /= ".".join([str(part) for part in sys.version_info[:2]]) for part in fpath_no_suffix.parts: cache_file /= part data_json_file = cache_file.with_suffix(".data.json") if data_json_file.exists(): data_json_file.unlink() meta_json_file = cache_file.with_suffix(".meta.json") if meta_json_file.exists(): meta_json_file.unlink() for parent_dir in cache_file.parents: if ( parent_dir.exists() and len(list(parent_dir.iterdir())) == 0 and str(self.incremental_cache_dir) in str(parent_dir) ): parent_dir.rmdir() def typecheck_in_new_subprocess( self, execution_path: Path, mypy_cmd_options: List[Any] ) -> Tuple[int, Tuple[str, str]]: import shutil mypy_executable = shutil.which("mypy") assert mypy_executable is not None, "mypy executable is not found" rootdir = getattr(getattr(self.parent, "config", None), "rootdir", None) # add current directory to path self._collect_python_path(rootdir, execution_path) # adding proper MYPYPATH variable self._collect_mypy_path(rootdir) # Windows requires this to be set, otherwise the interpreter crashes if "SYSTEMROOT" in os.environ: self.environment_variables["SYSTEMROOT"] = os.environ["SYSTEMROOT"] completed = subprocess.run( [mypy_executable, *mypy_cmd_options], capture_output=True, cwd=os.getcwd(), env=self.environment_variables, ) captured_stdout = completed.stdout.decode() captured_stderr = completed.stderr.decode() return completed.returncode, (captured_stdout, captured_stderr) def typecheck_in_same_process( self, execution_path: Path, mypy_cmd_options: List[Any] ) -> Tuple[Optional[Union[str, int]], Tuple[str, str]]: with utils.temp_environ(), utils.temp_path(), utils.temp_sys_modules(): # add custom environment variables for key, val in self.environment_variables.items(): os.environ[key] = val # add current directory to path sys.path.insert(0, str(execution_path)) stdout = io.StringIO() stderr = io.StringIO() with stdout, stderr: return_code = run_mypy_typechecking(mypy_cmd_options, stdout=stdout, stderr=stderr) stdout_value = stdout.getvalue() stderr_value = stderr.getvalue() return return_code, (stdout_value, stderr_value) def execute_extension_hook(self) -> None: extension_hook_fqname = self.config.option.mypy_extension_hook module_name, func_name = extension_hook_fqname.rsplit(".", maxsplit=1) module = importlib.import_module(module_name) extension_hook = getattr(module, func_name) extension_hook(self) def runtest(self) -> None: try: temp_dir = tempfile.TemporaryDirectory(prefix="pytest-mypy-", dir=self.root_directory) except (FileNotFoundError, PermissionError, NotADirectoryError) as e: raise TypecheckAssertionError( error_message=f"Testing base directory {self.root_directory} must exist and be writable" ) from e try: execution_path = Path(temp_dir.name) with utils.cd(execution_path): # extension point for derived packages if ( hasattr(self.config.option, "mypy_extension_hook") and self.config.option.mypy_extension_hook is not None ): self.execute_extension_hook() # start from main.py main_file = str(execution_path / "main.py") mypy_cmd_options = self.prepare_mypy_cmd_options(execution_path) mypy_cmd_options.append(main_file) # make files self.make_test_files_in_current_directory() if self.same_process: returncode, (stdout, stderr) = self.typecheck_in_same_process(execution_path, mypy_cmd_options) else: returncode, (stdout, stderr) = self.typecheck_in_new_subprocess(execution_path, mypy_cmd_options) mypy_output = stdout + stderr if returncode == ReturnCodes.FATAL_ERROR: print(mypy_output, file=sys.stderr) raise TypecheckAssertionError(error_message="Critical error occurred") output_lines = [] for line in mypy_output.splitlines(): output_line = replace_fpath_with_module_name(line, rootdir=execution_path) output_lines.append(output_line) try: assert_expected_matched_actual(expected=self.expected_output, actual=output_lines) except TypecheckAssertionError as e: if not self.expect_fail: raise e else: if self.expect_fail: raise TypecheckAssertionError("Expected failure, but test passed") finally: temp_dir.cleanup() # remove created modules if not self.disable_cache: for file in self.files: path = Path(file.path) self.remove_cache_files(path.with_suffix("")) assert not os.path.exists(temp_dir.name) def prepare_mypy_cmd_options(self, execution_path: Path) -> List[str]: mypy_cmd_options = [ "--show-traceback", "--no-error-summary", "--no-pretty", "--hide-error-context", ] if not self.test_only_local_stub: mypy_cmd_options.append("--no-silence-site-packages") if not self.disable_cache: mypy_cmd_options.extend(["--cache-dir", self.incremental_cache_dir]) config_file = self.prepare_config_file(execution_path) if config_file: mypy_cmd_options.append(f"--config-file={config_file}") return mypy_cmd_options def prepare_config_file(self, execution_path: Path) -> Optional[str]: # Merge (`self.base_ini_fpath` or `base_pyproject_toml_fpath`) # and `self.additional_mypy_config` # into one file and copy to the typechecking folder: if self.base_pyproject_toml_fpath: return configs.join_toml_configs( self.base_pyproject_toml_fpath, self.additional_mypy_config, execution_path ) elif self.base_ini_fpath or self.additional_mypy_config: # We might have `self.base_ini_fpath` set as well. # Or this might be a legacy case: only `mypy_config:` is set in the `yaml` test case. # This means that no real file is provided. return configs.join_ini_configs(self.base_ini_fpath, self.additional_mypy_config, execution_path) return None def repr_failure( self, excinfo: ExceptionInfo[BaseException], style: Optional["_TracebackStyle"] = None ) -> Union[str, TerminalRepr]: if excinfo.errisinstance(SystemExit): # We assume that before doing exit() (which raises SystemExit) we've printed # enough context about what happened so that a stack trace is not useful. # In particular, uncaught exceptions during semantic analysis or type checking # call exit() and they already print out a stack trace. return excinfo.exconly(tryshort=True) elif excinfo.errisinstance(TypecheckAssertionError): # with traceback removed exception_repr = excinfo.getrepr(style="short") exception_repr.reprcrash.message = "" # type: ignore repr_file_location = ReprFileLocation( path=self.fspath, lineno=self.starting_lineno + excinfo.value.lineno, message="" # type: ignore ) repr_tb_entry = TraceLastReprEntry( exception_repr.reprtraceback.reprentries[-1].lines[1:], None, None, repr_file_location, "short" ) exception_repr.reprtraceback.reprentries = [repr_tb_entry] return exception_repr else: return super().repr_failure(excinfo, style="native") def reportinfo(self) -> Tuple[Union[py.path.local, Path, str], Optional[int], str]: # To support both Pytest 6.x and 7.x path = getattr(self, "path", None) or getattr(self, "fspath") assert path return path, None, self.name def _collect_python_path( self, rootdir: Optional[Path], execution_path: Path, ) -> None: python_path_parts = [] existing_python_path = os.environ.get("PYTHONPATH") if existing_python_path: python_path_parts.append(existing_python_path) if execution_path: python_path_parts.append(str(execution_path)) python_path_key = self.environment_variables.get("PYTHONPATH") if python_path_key: python_path_parts.append(maybe_to_abspath(python_path_key, rootdir)) python_path_parts.append(python_path_key) self.environment_variables["PYTHONPATH"] = ":".join(python_path_parts) def _collect_mypy_path(self, rootdir: Optional[Path]) -> None: mypy_path_parts = [] existing_mypy_path = os.environ.get("MYPYPATH") if existing_mypy_path: mypy_path_parts.append(existing_mypy_path) mypy_path_key = self.environment_variables.get("MYPYPATH") if mypy_path_key: mypy_path_parts.append(maybe_to_abspath(mypy_path_key, rootdir)) mypy_path_parts.append(mypy_path_key) if rootdir: mypy_path_parts.append(str(rootdir)) self.environment_variables["MYPYPATH"] = ":".join(mypy_path_parts) typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/py.typed000066400000000000000000000000001460232061300263520ustar00rootroot00000000000000typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/schema.json000066400000000000000000000114321460232061300270210ustar00rootroot00000000000000{ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://raw.githubusercontent.com/typeddjango/pytest-mypy-plugins/master/pytest_mypy_plugins/schema.json", "title": "pytest-mypy-plugins test file", "description": "JSON Schema for a pytest-mypy-plugins test file", "type": "array", "items": { "type": "object", "additionalProperties": true, "properties": { "case": { "type": "string", "pattern": "^[a-zA-Z0-9_]+$", "description": "Name of the test case, MUST comply to the `^[a-zA-Z0-9_]+$` pattern.", "examples": [ { "case": "TestCase1" }, { "case": "999" }, { "case": "test_case_1" } ] }, "main": { "type": "string", "description": "Portion of the code as if written in `.py` file. Must be valid Python code.", "examples": [ { "main": "reveal_type(1)" } ] }, "out": { "type": "string", "description": "Verbose output expected from `mypy`.", "examples": [ { "out": "main:1: note: Revealed type is \"Literal[1]?\"" } ] }, "files": { "type": "array", "items": { "$ref": "#/definitions/File" }, "description": "List of extra files to simulate imports if needed.", "examples": [ [ { "path": "myapp/__init__.py" }, { "path": "myapp/utils.py", "content": "def help(): pass" } ] ] }, "disable_cache": { "type": "boolean", "description": "Set to `true` disables `mypy` caching.", "default": false }, "mypy_config": { "type": "string", "description": "Inline `mypy` configuration, passed directly to `mypy`.", "examples": [ { "mypy_config": "force_uppercase_builtins = true\nforce_union_syntax = true\n" } ] }, "env": { "type": "array", "items": { "type": "string" }, "description": "Environmental variables to be provided inside of test run.", "examples": [ "MYPYPATH=../extras", "DJANGO_SETTINGS_MODULE=mysettings" ] }, "parametrized": { "type": "array", "items": { "$ref": "#/definitions/Parameter" }, "description": "List of parameters, similar to [`@pytest.mark.parametrize`](https://docs.pytest.org/en/stable/parametrize.html). Each entry **must** have the **exact** same set of keys.", "examples": [ [ { "val": 1, "rt": "int" }, { "val": "1", "rt": "str" } ] ] }, "skip": { "anyOf": [ { "type": "boolean" }, { "type": "string" } ], "description": "An expression set in `skip` is passed directly into [`eval`](https://docs.python.org/3/library/functions.html#eval). It is advised to take a peek and learn about how `eval` works. Expression evaluated with following globals set: `sys`, `os`, `pytest` and `platform`.", "examples": [ "yes", true, "sys.version_info > (2, 0)" ], "default": false }, "expect_fail": { "type": "boolean", "description": "Mark test case as an expected failure.", "default": false }, "regex": { "type": "boolean", "description": "Allow regular expressions in comments to be matched against actual output. _See pytest_mypy_plugins/tests/test-regex_assertions.yml for examples_", "default": false } }, "required": [ "case", "main" ] }, "definitions": { "File": { "type": "object", "properties": { "path": { "type": "string", "description": "File path.", "examples": [ "../extras/extra_module.py", "myapp/__init__.py" ] }, "content": { "type": "string", "description": "File content. Can be empty. Must be valid Python code.", "examples": [ "def help(): pass", "def help():\n pass\n" ] } }, "required": [ "path" ] }, "Parameter": { "type": "object", "additionalProperties": true, "description": "A mapping of keys to values, similar to Python's `Mapping[str, Any]`.", "examples": [ { "val": "1", "rt": "str" } ] } } } typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/000077500000000000000000000000001460232061300260275ustar00rootroot00000000000000typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/reveal_type_hook.py000066400000000000000000000005421460232061300317410ustar00rootroot00000000000000from pytest_mypy_plugins.item import YamlTestItem def hook(item: YamlTestItem) -> None: parsed_test_data = item.parsed_test_data obj_to_reveal = parsed_test_data.get("reveal_type") if obj_to_reveal: for file in item.files: if file.path.endswith("main.py"): file.content = f"reveal_type({obj_to_reveal})" typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test-extension.yml000066400000000000000000000002671460232061300315500ustar00rootroot00000000000000- case: reveal_type_extension_is_loaded main: | # if hook works, main should contain 'reveal_type(1)' reveal_type: 1 out: | main:1: note: Revealed type is "Literal[1]?" typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test-mypy-config.yml000066400000000000000000000003651460232061300317740ustar00rootroot00000000000000# Also used in `test_explicit_configs.py` - case: custom_mypy_config_disallow_any_explicit_set expect_fail: yes main: | from typing import Any a: Any = None # should raise an error mypy_config: | disallow_any_explicit = true typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test-parametrized.yml000066400000000000000000000026721460232061300322250ustar00rootroot00000000000000- case: only_main parametrized: - a: 1 revealed_type: builtins.int - a: 1.0 revealed_type: builtins.float main: | a = {{ a }} reveal_type(a) # N: Revealed type is "{{ revealed_type }}" - case: with_extra parametrized: - a: 2 b: null rt: Any - a: 3 b: 3 rt: Any main: | import foo reveal_type(foo.test({{ a }}, {{ b }})) # N: Revealed type is "{{ rt }}" files: - path: foo.py content: | from typing import Any def test(a: Any, b: Any) -> Any: ... - case: with_out parametrized: - what: cat rt: builtins.str - what: dog rt: builtins.str main: | animal = '{{ what }}' reveal_type(animal) try: animal / 2 except Exception: ... out: | main:2: note: Revealed type is "{{ rt }}" main:4: error: Unsupported operand types for / ("str" and "int") [operator] - case: parametrized_can_skip_mypy_config_section parametrized: - val: False - val: True mypy_config: | hide_error_codes = True main: | a = {{ val }} a.lower() # E: "bool" has no attribute "lower" - case: with_mypy_config parametrized: - allow_any: "true" - allow_any: "false" mypy_config: | disallow_any_explicit = {{ allow_any }} main: | # Anything will work, we just need to be sure that # `disallow_any_generics: Not a boolean: {{ allow_any }}` # is not raised 1 + 1 typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test-paths-from-env.yml000066400000000000000000000005221460232061300323740ustar00rootroot00000000000000- case: add_mypypath_env_var_to_package_search main: | import extra_module extra_module.extra_fn() extra_module.missing() # E: Module has no attribute "missing" [attr-defined] env: - MYPYPATH=../extras files: - path: ../extras/extra_module.py content: | def extra_fn() -> None: pass typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test-regex-assertions.yml000066400000000000000000000022331460232061300330310ustar00rootroot00000000000000- case: expected_message_regex regex: yes main: | a = 1 b = 'hello' reveal_type(a) # N: Revealed type is "builtins.int" reveal_type(b) # N: .*str.* - case: expected_message_regex_with_out regex: yes main: | a = 'abc' reveal_type(a) out: | main:2: note: .*str.* - case: regex_with_out_does_not_hang expect_fail: yes regex: yes main: | 'abc'.split(4) out: | main:1: error: Argument 1 to "split" of "str" has incompatible type "int"; expected "Optional[str]" - case: regex_with_comment_does_not_hang expect_fail: yes regex: yes main: | a = 'abc'.split(4) # E: Argument 1 to "split" of "str" has incompatible type "int"; expected "Optional[str]" - case: expected_single_message_regex regex: no main: | a = 'hello' reveal_type(a) # NR: .*str.* - case: rexex_but_not_turned_on expect_fail: yes main: | a = 'hello' reveal_type(a) # N: .*str.* - case: rexex_but_turned_off expect_fail: yes regex: no main: | a = 'hello' reveal_type(a) # N: .*str.* - case: regex_does_not_match expect_fail: yes regex: no main: | a = 'hello' reveal_type(a) # NR: .*banana.* typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test-simple-cases.yml000066400000000000000000000034211460232061300321140ustar00rootroot00000000000000- case: simplest_case main: | a = 1 b = 'hello' class MyClass: pass reveal_type(a) # N: Revealed type is "builtins.int" reveal_type(b) # N: Revealed type is "builtins.str" - case: revealed_type_with_environment main: | a = 1 class MyClass: def __init__(self): pass b = 'hello' reveal_type(a) # N: Revealed type is "builtins.int" reveal_type(b) # N: Revealed type is "builtins.str" env: - DJANGO_SETTINGS_MODULE=mysettings - case: revealed_type_with_disabled_cache main: | a = 1 reveal_type(a) # N: Revealed type is "builtins.int" disable_cache: true - case: external_output_lines main: | a = 1 reveal_type(a) out: | main:2: note: Revealed type is "builtins.int" - case: create_files main: | a = 1 reveal_type(a) # N: Revealed type is "builtins.int" files: - path: myapp/__init__.py - path: myapp/models.py content: | from django.db import models class MyModel: pass - path: myapp/apps.py - case: error_case main: | a = 1 a.lower() # E: "int" has no attribute "lower" [attr-defined] - case: skip_incorrect_test_case skip: yes main: | a = 1 reveal_type(a) # N: boom! - case: skip_if_true skip: sys.version_info > (2, 0) main: | a = 1 a.lower() # E: boom! - case: skip_if_false skip: sys.version_info < (0, 0) main: | a = 1 a.lower() # E: "int" has no attribute "lower" [attr-defined] - case: fail_if_message_does_not_match expect_fail: yes main: | a = 'hello' reveal_type(a) # N: Some other message - case: fail_if_message_from_outdoes_not_match expect_fail: yes main: | a = 'abc' reveal_type(a) out: | main:2: note: Some other message typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/000077500000000000000000000000001460232061300305165ustar00rootroot00000000000000typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/mypy1.ini000066400000000000000000000000351460232061300322740ustar00rootroot00000000000000[mypy] show_traceback = true typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/mypy2.ini000066400000000000000000000000101460232061300322660ustar00rootroot00000000000000# Empty typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/pyproject1.toml000066400000000000000000000002751460232061300335170ustar00rootroot00000000000000# This file has `[tool.mypy]` existing config [tool.mypy] warn_unused_ignores = true pretty = true show_error_codes = true [tool.other] # This section should not be copied: key = 'value' typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/pyproject2.toml000066400000000000000000000000611460232061300335110ustar00rootroot00000000000000# This file has no `[tool.mypy]` existing config typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/pyproject3.toml000066400000000000000000000003471460232061300335210ustar00rootroot00000000000000# This file has `[tool.mypy]` existing config [tool.mypy] warn_unused_ignores = true pretty = true show_error_codes = true [[tool.mypy.overrides]] # This section should be copied module = "mymodule" ignore_missing_imports = true typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/setup1.cfg000066400000000000000000000000351460232061300324160ustar00rootroot00000000000000[mypy] show_traceback = true typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/setup2.cfg000066400000000000000000000000101460232061300324100ustar00rootroot00000000000000# Empty test_join_toml_configs.py000066400000000000000000000064051460232061300355570ustar00rootroot00000000000000typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configsfrom pathlib import Path from textwrap import dedent from typing import Callable, Final, Optional import pytest from pytest_mypy_plugins.configs import join_toml_configs _ADDITIONAL_CONFIG: Final = """ [tool.mypy] pretty = true show_error_codes = false show_traceback = true """ _ADDITIONAL_CONFIG_NO_TABLE: Final = """ pretty = true show_error_codes = false show_traceback = true """ _PYPROJECT1: Final = str(Path(__file__).parent / "pyproject1.toml") _PYPROJECT2: Final = str(Path(__file__).parent / "pyproject2.toml") _PYPROJECT3: Final = str(Path(__file__).parent / "pyproject3.toml") @pytest.fixture def execution_path(tmpdir_factory: pytest.TempdirFactory) -> Path: return Path(tmpdir_factory.mktemp("testproject", numbered=True)) _AssertFileContents = Callable[[Optional[str], str], None] @pytest.fixture def assert_file_contents() -> _AssertFileContents: def factory(filename: Optional[str], expected: str) -> None: assert filename expected = dedent(expected).strip() with open(filename) as f: contents = f.read().strip() assert contents == expected return factory @pytest.mark.parametrize( "additional_config", [ _ADDITIONAL_CONFIG, _ADDITIONAL_CONFIG_NO_TABLE, ], ) def test_join_existing_config( execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str ) -> None: filepath = join_toml_configs(_PYPROJECT1, additional_config, execution_path) assert_file_contents( filepath, """ [tool.mypy] warn_unused_ignores = true pretty = true show_error_codes = false show_traceback = true """, ) @pytest.mark.parametrize( "additional_config", [ _ADDITIONAL_CONFIG, _ADDITIONAL_CONFIG_NO_TABLE, ], ) def test_join_missing_config( execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str ) -> None: filepath = join_toml_configs(_PYPROJECT2, additional_config, execution_path) assert_file_contents( filepath, """ [tool.mypy] pretty = true show_error_codes = false show_traceback = true """, ) def test_join_missing_config1(execution_path: Path, assert_file_contents: _AssertFileContents) -> None: filepath = join_toml_configs(_PYPROJECT1, "", execution_path) assert_file_contents( filepath, """ [tool.mypy] warn_unused_ignores = true pretty = true show_error_codes = true """, ) def test_join_missing_config2(execution_path: Path, assert_file_contents: _AssertFileContents) -> None: filepath = join_toml_configs(_PYPROJECT2, "", execution_path) assert_file_contents( filepath, "[tool.mypy]", ) def test_join_missing_config3(execution_path: Path, assert_file_contents: _AssertFileContents) -> None: filepath = join_toml_configs(_PYPROJECT3, "", execution_path) assert_file_contents( filepath, """ [tool.mypy] warn_unused_ignores = true pretty = true show_error_codes = true [[tool.mypy.overrides]] # This section should be copied module = "mymodule" ignore_missing_imports = true """, ) typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_explicit_configs.py000066400000000000000000000023671460232061300330010ustar00rootroot00000000000000import subprocess from pathlib import Path from typing import Final import pytest _PYPROJECT1: Final = str(Path(__file__).parent / "test_configs" / "pyproject1.toml") _PYPROJECT2: Final = str(Path(__file__).parent / "test_configs" / "pyproject2.toml") _MYPYINI1: Final = str(Path(__file__).parent / "test_configs" / "mypy1.ini") _MYPYINI2: Final = str(Path(__file__).parent / "test_configs" / "mypy2.ini") _SETUPCFG1: Final = str(Path(__file__).parent / "test_configs" / "setup1.cfg") _SETUPCFG2: Final = str(Path(__file__).parent / "test_configs" / "setup2.cfg") _TEST_FILE: Final = str(Path(__file__).parent / "test-mypy-config.yml") @pytest.mark.parametrize("config_file", [_PYPROJECT1, _PYPROJECT2]) def test_pyproject_toml(config_file: str) -> None: subprocess.check_output( [ "pytest", "--mypy-pyproject-toml-file", config_file, _TEST_FILE, ] ) @pytest.mark.parametrize( "config_file", [ _MYPYINI1, _MYPYINI2, _SETUPCFG1, _SETUPCFG2, ], ) def test_ini_files(config_file: str) -> None: subprocess.check_output( [ "pytest", "--mypy-ini-file", config_file, _TEST_FILE, ] ) typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_input_schema.py000066400000000000000000000031671460232061300321260ustar00rootroot00000000000000import pathlib from typing import Sequence import jsonschema import pytest import yaml from pytest_mypy_plugins.collect import validate_schema def get_all_yaml_files(dir_path: pathlib.Path) -> Sequence[pathlib.Path]: yaml_files = [] for file in dir_path.rglob("*"): if file.suffix in (".yml", ".yaml"): yaml_files.append(file) return yaml_files files = get_all_yaml_files(pathlib.Path(__file__).parent) @pytest.mark.parametrize("yaml_file", files, ids=lambda x: x.stem) def test_yaml_files(yaml_file: pathlib.Path) -> None: validate_schema(yaml.safe_load(yaml_file.read_text())) def test_mypy_config_is_not_an_object() -> None: with pytest.raises(jsonschema.exceptions.ValidationError) as ex: validate_schema( [ { "case": "mypy_config_is_not_an_object", "main": "False", "mypy_config": [{"force_uppercase_builtins": True}, {"force_union_syntax": True}], } ] ) assert ( ex.value.message == "[{'force_uppercase_builtins': True}, {'force_union_syntax': True}] is not of type 'string'" ) def test_closed_schema() -> None: with pytest.raises(jsonschema.exceptions.ValidationError) as ex: validate_schema( [ { "case": "mypy_config_is_not_an_object", "main": "False", "extra_field": 1, } ], is_closed=True, ) assert ex.value.message == "Additional properties are not allowed ('extra_field' was unexpected)" typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_utils.py000066400000000000000000000206671460232061300306130ustar00rootroot00000000000000# encoding=utf-8 from typing import List, NamedTuple import pytest from pytest_mypy_plugins import utils from pytest_mypy_plugins.utils import ( OutputMatcher, TypecheckAssertionError, assert_expected_matched_actual, extract_output_matchers_from_comments, sorted_by_file_and_line, ) class ExpectMatchedActualTestData(NamedTuple): source_lines: List[str] actual_lines: List[str] expected_message_lines: List[str] def test_render_template_with_None_value() -> None: # Given template = "{{ a }} {{ b }}" data = {"a": None, "b": 99} # When actual = utils.render_template(template=template, data=data) # Then assert actual == "None 99" expect_matched_actual_data = [ ExpectMatchedActualTestData( [ '''reveal_type(42) # N: Revealed type is "Literal['foo']?"''', '''reveal_type("foo") # N: Revealed type is "Literal[42]?"''', ], ['''main:1: note: Revealed type is "Literal[42]?"''', '''main:2: note: Revealed type is "Literal['foo']?"'''], [ """Invalid output: """, """Actual:""", """ main:1: note: Revealed type is "Literal[42]?" (diff)""", """ main:2: note: Revealed type is "Literal['foo']?" (diff)""", """Expected:""", """ main:1: note: Revealed type is "Literal['foo']?" (diff)""", """ main:2: note: Revealed type is "Literal[42]?" (diff)""", """Alignment of first line difference:""", ''' E: ...ed type is "Literal['foo']?"''', ''' A: ...ed type is "Literal[42]?"''', """ ^""", ], ), ExpectMatchedActualTestData( [ """reveal_type(42)""", '''reveal_type("foo") # N: Revealed type is "Literal['foo']?"''', ], ['''main:1: note: Revealed type is "Literal[42]?"''', '''main:2: note: Revealed type is "Literal['foo']?"'''], [ """Invalid output: """, """Actual:""", """ main:1: note: Revealed type is "Literal[42]?" (diff)""", """ main:2: note: Revealed type is "Literal['foo']?" (diff)""", """Expected:""", """ main:2: note: Revealed type is "Literal['foo']?" (diff)""", """Alignment of first line difference:""", ''' E: main:2: note: Revealed type is "Literal['foo']?"''', ''' A: main:1: note: Revealed type is "Literal[42]?"''', """ ^""", ], ), ExpectMatchedActualTestData( ['''reveal_type(42) # N: Revealed type is "Literal[42]?"''', """reveal_type("foo")"""], ['''main:1: note: Revealed type is "Literal[42]?"''', '''main:2: note: Revealed type is "Literal['foo']?"'''], [ """Invalid output: """, """Actual:""", """ main:2: note: Revealed type is "Literal['foo']?" (diff)""", """Expected:""", """ (empty)""", ], ), ExpectMatchedActualTestData( ['''42 + "foo"'''], ["""main:1: error: Unsupported operand types for + ("int" and "str")"""], [ """Output is not expected: """, """Actual:""", """ main:1: error: Unsupported operand types for + ("int" and "str") (diff)""", """Expected:""", """ (empty)""", ], ), ExpectMatchedActualTestData( [""" 1 + 1 # E: Unsupported operand types for + ("int" and "int")"""], [], [ """Invalid output: """, """Actual:""", """ (empty)""", """Expected:""", """ main:1: error: Unsupported operand types for + ("int" and "int") (diff)""", ], ), ExpectMatchedActualTestData( [ '''reveal_type(42) # N: Revealed type is "Literal[42]?"''', '''reveal_type("foo") # N: Revealed type is "builtins.int"''', ], ['''main:1: note: Revealed type is "Literal[42]?"''', '''main:2: note: Revealed type is "Literal['foo']?"'''], [ """Invalid output: """, """Actual:""", """ ...""", """ main:2: note: Revealed type is "Literal['foo']?" (diff)""", """Expected:""", """ ...""", """ main:2: note: Revealed type is "builtins.int" (diff)""", """Alignment of first line difference:""", ''' E: ...te: Revealed type is "builtins.int"''', ''' A: ...te: Revealed type is "Literal['foo']?"''', """ ^""", ], ), ExpectMatchedActualTestData( [ '''reveal_type(42) # N: Revealed type is "Literal[42]?"''', '''reveal_type("foo") # N: Revealed type is "builtins.int"''', ], ['''main:1: note: Revealed type is "Literal[42]?"''', '''main:2: note: Revealed type is "Literal['foo']?"'''], [ """Invalid output: """, """Actual:""", """ ...""", """ main:2: note: Revealed type is "Literal['foo']?" (diff)""", """Expected:""", """ ...""", """ main:2: note: Revealed type is "builtins.int" (diff)""", """Alignment of first line difference:""", ''' E: ...te: Revealed type is "builtins.int"''', ''' A: ...te: Revealed type is "Literal['foo']?"''', """ ^""", ], ), ExpectMatchedActualTestData( [ '''reveal_type(42.0) # N: Revealed type is "builtins.float"''', '''reveal_type("foo") # N: Revealed type is "builtins.int"''', '''reveal_type(42) # N: Revealed type is "Literal[42]?"''', ], [ '''main:1: note: Revealed type is "builtins.float"''', '''main:2: note: Revealed type is "Literal['foo']?"''', '''main:3: note: Revealed type is "Literal[42]?"''', ], [ """Invalid output: """, """Actual:""", """ ...""", """ main:2: note: Revealed type is "Literal['foo']?" (diff)""", """ ...""", """Expected:""", """ ...""", """ main:2: note: Revealed type is "builtins.int" (diff)""", """ ...""", """Alignment of first line difference:""", ''' E: ...te: Revealed type is "builtins.int"''', ''' A: ...te: Revealed type is "Literal['foo']?"''', """ ^""", ], ), ] @pytest.mark.parametrize("source_lines,actual_lines,expected_message_lines", expect_matched_actual_data) def test_assert_expected_matched_actual_failures( source_lines: List[str], actual_lines: List[str], expected_message_lines: List[str] ) -> None: expected: List[OutputMatcher] = extract_output_matchers_from_comments("main", source_lines, False) expected_error_message = "\n".join(expected_message_lines) with pytest.raises(TypecheckAssertionError) as e: assert_expected_matched_actual(expected, actual_lines) assert e.value.error_message.strip() == expected_error_message.strip() @pytest.mark.parametrize( "input_lines", [ [ '''main:12: error: No overload variant of "f" matches argument type "List[int]"''', """main:12: note: Possible overload variants:""", """main:12: note: def f(x: int) -> int""", """main:12: note: def f(x: str) -> str""", ], [ '''main_a:12: error: No overload variant of "g" matches argument type "List[int]"''', '''main_b:12: error: No overload variant of "f" matches argument type "List[int]"''', """main_b:12: note: Possible overload variants:""", """main_a:12: note: def g(b: int) -> int""", """main_a:12: note: def g(a: int) -> int""", """main_b:12: note: def f(x: int) -> int""", """main_b:12: note: def f(x: str) -> str""", ], ], ) def test_sorted_by_file_and_line_is_stable(input_lines: List[str]) -> None: def lines_for_file(lines: List[str], fname: str) -> List[str]: prefix = f"{fname}:" return [line for line in lines if line.startswith(prefix)] files = sorted({line.split(":", maxsplit=1)[0] for line in input_lines}) sorted_lines = sorted_by_file_and_line(input_lines) for f in files: assert lines_for_file(sorted_lines, f) == lines_for_file(input_lines, f) typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/utils.py000066400000000000000000000301201460232061300263730ustar00rootroot00000000000000# Borrowed from Pew. # See https://github.com/berdario/pew/blob/master/pew/_utils.py#L82 import contextlib import inspect import io import os import re import sys from dataclasses import dataclass from itertools import zip_longest from pathlib import Path from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Tuple, Union import jinja2 import regex from decorator import contextmanager _rendering_env = jinja2.Environment() @contextmanager def temp_environ() -> Iterator[None]: """Allow the ability to set os.environ temporarily""" environ = dict(os.environ) try: yield finally: os.environ.clear() os.environ.update(environ) @contextmanager def temp_path() -> Iterator[None]: """A context manager which allows the ability to set sys.path temporarily""" path = sys.path[:] try: yield finally: sys.path = path[:] @contextmanager def temp_sys_modules() -> Iterator[None]: sys_modules = sys.modules.copy() try: yield finally: sys.modules = sys_modules.copy() def fname_to_module(fpath: Path, root_path: Path) -> Optional[str]: try: relpath = fpath.relative_to(root_path).with_suffix("") return str(relpath).replace(os.sep, ".") except ValueError: return None # AssertStringArraysEqual displays special line alignment helper messages if # the first different line has at least this many characters, MIN_LINE_LENGTH_FOR_ALIGNMENT = 5 @dataclass class OutputMatcher: fname: str lnum: int severity: str message: str regex: bool col: Optional[str] = None def matches(self, actual: str) -> bool: if self.regex: pattern = ( regex.escape( f"{self.fname}:{self.lnum}: {self.severity}: " if self.col is None else f"{self.fname}:{self.lnum}:{self.col}: {self.severity}: " ) + self.message ) return regex.match(pattern, actual) else: return str(self) == actual def __str__(self) -> str: if self.col is None: return f"{self.fname}:{self.lnum}: {self.severity}: {self.message}" else: return f"{self.fname}:{self.lnum}:{self.col}: {self.severity}: {self.message}" def __format__(self, format_spec: str) -> str: return format_spec.format(str(self)) def __len__(self) -> int: return len(str(self)) class TypecheckAssertionError(AssertionError): def __init__(self, error_message: Optional[str] = None, lineno: int = 0) -> None: self.error_message = error_message or "" self.lineno = lineno def first_line(self) -> str: return self.__class__.__name__ + '(message="Invalid output")' def __str__(self) -> str: return self.error_message def remove_common_prefix(lines: List[str]) -> List[str]: """Remove common directory prefix from all strings in a. This uses a naive string replace; it seems to work well enough. Also remove trailing carriage returns. """ cleaned_lines = [] for line in lines: # Ignore spaces at end of line. line = re.sub(" +$", "", line) cleaned_lines.append(re.sub("\\r$", "", line)) return cleaned_lines def _add_aligned_message(s1: str, s2: str, error_message: str) -> str: """Align s1 and s2 so that the their first difference is highlighted. For example, if s1 is 'foobar' and s2 is 'fobar', display the following lines: E: foobar A: fobar ^ If s1 and s2 are long, only display a fragment of the strings around the first difference. If s1 is very short, do nothing. """ # Seeing what went wrong is trivial even without alignment if the expected # string is very short. In this case do nothing to simplify output. if len(s1) < 4: return error_message maxw = 72 # Maximum number of characters shown error_message += "Alignment of first line difference:\n" assert s1 != s2 trunc = False while s1[:30] == s2[:30]: s1 = s1[10:] s2 = s2[10:] trunc = True if trunc: s1 = "..." + s1 s2 = "..." + s2 max_len = max(len(s1), len(s2)) extra = "" if max_len > maxw: extra = "..." # Write a chunk of both lines, aligned. error_message += f" E: {s1[:maxw]}{extra}\n" error_message += f" A: {s2[:maxw]}{extra}\n" # Write an indicator character under the different columns. error_message += " " # sys.stderr.write(' ') for j in range(min(maxw, max(len(s1), len(s2)))): if s1[j : j + 1] != s2[j : j + 1]: error_message += "^" break else: error_message += " " error_message += "\n" return error_message def remove_empty_lines(lines: List[str]) -> List[str]: filtered_lines = [] for line in lines: if line: filtered_lines.append(line) return filtered_lines def sorted_by_file_and_line(lines: List[str]) -> List[str]: def extract_parts_as_tuple(line: str) -> Tuple[str, int]: if len(line.split(":", maxsplit=2)) < 3: return "", 0 fname, line_number, _ = line.split(":", maxsplit=2) try: return fname, int(line_number) except ValueError: return "", 0 return sorted(lines, key=extract_parts_as_tuple) def assert_expected_matched_actual(expected: List[OutputMatcher], actual: List[str]) -> None: """Assert that two string arrays are equal. Display any differences in a human-readable form. """ def format_mismatched_line(line: str) -> str: return f" {str(line):<45} (diff)" def format_matched_line(line: str, width: int = 100) -> str: return f" {line[:width]}..." if len(line) > width else f" {line}" def format_error_lines(lines: List[str]) -> str: return "\n".join(lines) if lines else " (empty)" expected = sorted(expected, key=lambda om: (om.fname, om.lnum)) actual = sorted_by_file_and_line(remove_empty_lines(actual)) actual = remove_common_prefix(actual) diff_lines: Dict[int, Tuple[OutputMatcher, str]] = { i: (e, a) for i, (e, a) in enumerate(zip_longest(expected, actual)) if e is None or a is None or not e.matches(a) } if diff_lines: first_diff_line = min(diff_lines.keys()) last_diff_line = max(diff_lines.keys()) expected_message_lines = [] actual_message_lines = [] for i in range(first_diff_line, last_diff_line + 1): if i in diff_lines: expected_line, actual_line = diff_lines[i] if expected_line: expected_message_lines.append(format_mismatched_line(str(expected_line))) if actual_line: actual_message_lines.append(format_mismatched_line(actual_line)) else: expected_line, actual_line = expected[i], actual[i] actual_message_lines.append(format_matched_line(actual_line)) expected_message_lines.append(format_matched_line(str(expected_line))) first_diff_expected, first_diff_actual = diff_lines[first_diff_line] failure_reason = "Output is not expected" if actual and not expected else "Invalid output" if actual_message_lines and expected_message_lines: if first_diff_line > 0: expected_message_lines.insert(0, " ...") actual_message_lines.insert(0, " ...") if last_diff_line < len(actual) - 1 and last_diff_line < len(expected) - 1: expected_message_lines.append(" ...") actual_message_lines.append(" ...") error_message = "Actual:\n{}\nExpected:\n{}\n".format( format_error_lines(actual_message_lines), format_error_lines(expected_message_lines) ) if expected_line and expected_line.regex: error_message += "The actual output does not match the expected regex." elif ( first_diff_actual is not None and first_diff_expected is not None and ( len(first_diff_actual) >= MIN_LINE_LENGTH_FOR_ALIGNMENT or len(str(first_diff_expected)) >= MIN_LINE_LENGTH_FOR_ALIGNMENT ) ): error_message = _add_aligned_message(str(first_diff_expected), first_diff_actual, error_message) raise TypecheckAssertionError( error_message=f"{failure_reason}: \n{error_message}", lineno=first_diff_expected.lnum if first_diff_expected else 0, ) def extract_output_matchers_from_comments(fname: str, input_lines: List[str], regex: bool) -> List[OutputMatcher]: """Transform comments such as '# E: message' or '# E:3: message' in input. The result is a list pf output matchers """ fname = fname.replace(".py", "") matchers = [] for index, line in enumerate(input_lines): # The first in the split things isn't a comment for possible_err_comment in line.split(" # ")[1:]: match = re.search( r"^([ENW])(?P[R])?:((?P\d+):)? (?P.*)$", possible_err_comment.strip() ) if match: if match.group(1) == "E": severity = "error" elif match.group(1) == "N": severity = "note" elif match.group(1) == "W": severity = "warning" else: severity = match.group(1) col = match.group("col") matchers.append( OutputMatcher( fname, index + 1, severity, message=match.group("message"), regex=regex or bool(match.group("regex")), col=col, ) ) return matchers def extract_output_matchers_from_out(out: str, params: Mapping[str, Any], regex: bool) -> List[OutputMatcher]: """Transform output lines such as 'function:9: E: message' The result is a list of output matchers """ matchers = [] lines = render_template(out, params).split("\n") for line in lines: match = re.search( r"^(?P.*):(?P\d*): (?P.*):((?P\d+):)? (?P.*)$", line.strip() ) if match: if match.group("severity") == "E": severity = "error" elif match.group("severity") == "N": severity = "note" elif match.group("severity") == "W": severity = "warning" else: severity = match.group("severity") col = match.group("col") matchers.append( OutputMatcher( match.group("fname"), int(match.group("lnum")), severity, message=match.group("message"), regex=regex, col=col, ) ) return matchers def render_template(template: str, data: Mapping[str, Any]) -> str: if _rendering_env.variable_start_string not in template: return template t: jinja2.environment.Template = _rendering_env.from_string(template) return t.render({k: v if v is not None else "None" for k, v in data.items()}) def get_func_first_lnum(attr: Callable[..., None]) -> Optional[Tuple[int, List[str]]]: lines, _ = inspect.getsourcelines(attr) for lnum, line in enumerate(lines): no_space_line = line.strip() if f"def {attr.__name__}" in no_space_line: return lnum, lines[lnum + 1 :] raise ValueError(f'No line "def {attr.__name__}" found') @contextmanager def cd(path: Union[str, Path]) -> Iterator[None]: """Context manager to temporarily change working directories""" if not path: return prev_cwd = Path.cwd().as_posix() if isinstance(path, Path): path = path.as_posix() os.chdir(str(path)) try: yield finally: os.chdir(prev_cwd) typeddjango-pytest-mypy-plugins-be9eb51/release.sh000077500000000000000000000006021460232061300224730ustar00rootroot00000000000000#!/usr/bin/env bash set -ex if [[ -z $(git status -s) ]] then if [[ "$VIRTUAL_ENV" != "" ]] then pip install --upgrade setuptools wheel twine python setup.py sdist bdist_wheel twine upload dist/* rm -rf dist/ build/ else echo "this script must be executed inside an active virtual env, aborting" fi else echo "git working tree is not clean, aborting" fi typeddjango-pytest-mypy-plugins-be9eb51/requirements.txt000066400000000000000000000000771460232061300240060ustar00rootroot00000000000000black isort types-decorator types-PyYAML types-setuptools -e . typeddjango-pytest-mypy-plugins-be9eb51/setup.py000066400000000000000000000026661460232061300222420ustar00rootroot00000000000000from setuptools import setup with open("README.md") as f: readme = f.read() dependencies = [ "Jinja2", "decorator", "jsonschema", "mypy>=1.3", "packaging", "pytest>=7.0.0", "pyyaml", "regex", "tomlkit>=0.11", ] setup( name="pytest-mypy-plugins", version="3.1.2", description="pytest plugin for writing tests for mypy plugins", long_description=readme, long_description_content_type="text/markdown", license="MIT", url="https://github.com/TypedDjango/pytest-mypy-plugins", author="Maksim Kurnikov", author_email="maxim.kurnikov@gmail.com", maintainer="Nikita Sobolev", maintainer_email="mail@sobolevn.me", packages=["pytest_mypy_plugins"], # the following makes a plugin available to pytest entry_points={"pytest11": ["pytest-mypy-plugins = pytest_mypy_plugins.collect"]}, install_requires=dependencies, python_requires=">=3.8", package_data={ "pytest_mypy_plugins": ["py.typed", "schema.json"], }, classifiers=[ "Development Status :: 4 - Beta", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Typing :: Typed", ], ) typeddjango-pytest-mypy-plugins-be9eb51/tox.ini000066400000000000000000000001221460232061300220240ustar00rootroot00000000000000[testenv] deps = -rrequirements.txt commands = python -m pytest {posargs}