pax_global_header 0000666 0000000 0000000 00000000064 14731620246 0014517 g ustar 00root root 0000000 0000000 52 comment=ccbfce7e5236b99db7b781f323e2c89fcee124dd
pytest-mypy-plugins-3.2.0/ 0000775 0000000 0000000 00000000000 14731620246 0015524 5 ustar 00root root 0000000 0000000 pytest-mypy-plugins-3.2.0/.editorconfig 0000664 0000000 0000000 00000000452 14731620246 0020202 0 ustar 00root root 0000000 0000000 # 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
pytest-mypy-plugins-3.2.0/.github/ 0000775 0000000 0000000 00000000000 14731620246 0017064 5 ustar 00root root 0000000 0000000 pytest-mypy-plugins-3.2.0/.github/dependabot.yml 0000664 0000000 0000000 00000000426 14731620246 0021716 0 ustar 00root root 0000000 0000000 version: 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
pytest-mypy-plugins-3.2.0/.github/workflows/ 0000775 0000000 0000000 00000000000 14731620246 0021121 5 ustar 00root root 0000000 0000000 pytest-mypy-plugins-3.2.0/.github/workflows/test.yml 0000664 0000000 0000000 00000002463 14731620246 0022630 0 ustar 00root root 0000000 0000000 name: test
on:
push:
branches:
- master
pull_request:
workflow_dispatch:
permissions:
contents: read
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.9", "3.10", "3.11", "3.12", "3.13"]
pytest-version: ["~=7.2", "~=8.3"]
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 }}"
- 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 .
isort --check --diff .
pytest-mypy-plugins-3.2.0/.gitignore 0000664 0000000 0000000 00000006315 14731620246 0017521 0 ustar 00root root 0000000 0000000 .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
pytest-mypy-plugins-3.2.0/CHANGELOG.md 0000664 0000000 0000000 00000005614 14731620246 0017343 0 ustar 00root root 0000000 0000000 # Version history
## 3.2.0
### Features
- Drops `python3.8` support
- Adds official `python3.13` support
### Bugfixes
- Fixes regex for colon output `:`, #155
- Fixes internal error with `TraceLastReprEntry`, #154
## 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
pytest-mypy-plugins-3.2.0/LICENSE 0000664 0000000 0000000 00000002036 14731620246 0016532 0 ustar 00root root 0000000 0000000 Copyright 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. pytest-mypy-plugins-3.2.0/MANIFEST.in 0000664 0000000 0000000 00000000140 14731620246 0017255 0 ustar 00root root 0000000 0000000 include requirements.txt
include tox.ini
include pyproject.toml
graft pytest_mypy_plugins/tests
pytest-mypy-plugins-3.2.0/README.md 0000664 0000000 0000000 00000022217 14731620246 0017007 0 ustar 00root root 0000000 0000000
# pytest plugin for testing mypy types, stubs, and plugins
[](https://github.com/typeddjango/pytest-mypy-plugins/actions/workflows/test.yml)
[](http://mypy-lang.org/)
[](https://gitter.im/mypy-django/Lobby)
[](https://pypi.org/project/pytest-mypy-plugins/)
[](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
```
### Asserting types
There are two ways to assert types.
The custom one and regular [`typing.assert_type`](https://docs.python.org/3/library/typing.html#typing.assert_type).
Our custom type assertion uses `reveal_type` helper and custom output matchers:
```yml
- case: using_reveal_type
main: |
instance = 1
reveal_type(instance) # N: Revealed type is 'builtins.int'
```
This method also allows to use `# E:` for matching exact error messages and codes.
But, you can also use regular `assert_type`, examples can be [found here](https://github.com/typeddjango/pytest-mypy-plugins/blob/master/pytest_mypy_plugins/tests/test-assert-type.yml).
### 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)
pytest-mypy-plugins-3.2.0/pyproject.toml 0000664 0000000 0000000 00000000551 14731620246 0020441 0 ustar 00root root 0000000 0000000 [tool.mypy]
strict = true
ignore_missing_imports = true
warn_unreachable = 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 = ["py39"]
[tool.isort]
include_trailing_comma = true
multi_line_output = 3
profile = "black"
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/ 0000775 0000000 0000000 00000000000 14731620246 0021673 5 ustar 00root root 0000000 0000000 pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/__init__.py 0000664 0000000 0000000 00000000000 14731620246 0023772 0 ustar 00root root 0000000 0000000 pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/collect.py 0000664 0000000 0000000 00000017576 14731620246 0023712 0 ustar 00root root 0000000 0000000 import 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 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
parsed_file = yaml.load(stream=self.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 bool(eval(skip_if, {"sys": sys, "os": os, "pytest": pytest, "platform": platform}))
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
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`)",
)
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/configs.py 0000664 0000000 0000000 00000004770 14731620246 0023705 0 ustar 00root root 0000000 0000000 from 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)
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/item.py 0000664 0000000 0000000 00000044036 14731620246 0023212 0 ustar 00root root 0000000 0000000 import importlib
import io
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Dict,
List,
Literal,
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
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,
)
if TYPE_CHECKING:
# pytest 8.3.0 renamed _TracebackStyle to TracebackStyle, but there is no syntax
# to assert what version you have using static conditions, so it has to be
# manually re-defined here. Once minimum supported pytest version is >= 8.3.0,
# the following can be replaced with `from _pytest._code.code import TracebackStyle`
TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"]
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) -> 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:
# The code to a SystemExit is optional
# From python docs, if the code is None then the exit code is 0
# Otherwise if the code is not an integer the exit code is 1
code = sysexit.code
if code is None:
code = 0
elif not isinstance(code, int):
code = 1
return code
finally:
fscache.flush()
if error_messages:
return ReturnCodes.FAIL
return ReturnCodes.SUCCESS
class MypyExecutor:
def __init__(
self,
same_process: bool,
rootdir: Union[Path, None],
execution_path: Path,
environment_variables: Dict[str, Any],
mypy_executable: str,
) -> None:
self.rootdir = rootdir
self.same_process = same_process
self.execution_path = execution_path
self.mypy_executable = mypy_executable
self.environment_variables = environment_variables
def execute(self, mypy_cmd_options: List[str]) -> Tuple[int, Tuple[str, str]]:
# Returns (returncode, (stdout, stderr))
if self.same_process:
return self._typecheck_in_same_process(mypy_cmd_options)
else:
return self._typecheck_in_new_subprocess(mypy_cmd_options)
def _typecheck_in_new_subprocess(self, mypy_cmd_options: List[Any]) -> Tuple[int, Tuple[str, str]]:
# add current directory to path
self._collect_python_path(self.rootdir)
# adding proper MYPYPATH variable
self._collect_mypy_path(self.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(
[self.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, mypy_cmd_options: List[Any]) -> Tuple[int, Tuple[str, str]]:
return_code = -1
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(self.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 _collect_python_path(self, rootdir: Optional[Path]) -> None:
python_path_parts = []
existing_python_path = os.environ.get("PYTHONPATH")
if existing_python_path:
python_path_parts.append(existing_python_path)
python_path_parts.append(str(self.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)
class OutputChecker:
def __init__(self, expect_fail: bool, execution_path: Path, expected_output: List[OutputMatcher]) -> None:
self.expect_fail = expect_fail
self.execution_path = execution_path
self.expected_output = expected_output
def check(self, ret_code: int, stdout: str, stderr: str) -> None:
mypy_output = stdout + stderr
if ret_code == 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=self.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")
class Runner:
def __init__(
self,
*,
files: List[File],
config: Config,
main_file: Path,
config_file: Optional[str],
disable_cache: bool,
mypy_executor: MypyExecutor,
output_checker: OutputChecker,
test_only_local_stub: bool,
incremental_cache_dir: str,
) -> None:
self.files = files
self.config = config
self.main_file = main_file
self.config_file = config_file
self.mypy_executor = mypy_executor
self.disable_cache = disable_cache
self.output_checker = output_checker
self.test_only_local_stub = test_only_local_stub
self.incremental_cache_dir = incremental_cache_dir
def run(self) -> None:
# start from main.py
mypy_cmd_options = self._prepare_mypy_cmd_options()
mypy_cmd_options.append(str(self.main_file))
# make files
for file in self.files:
self._make_test_file(file)
returncode, (stdout, stderr) = self.mypy_executor.execute(mypy_cmd_options)
self.output_checker.check(returncode, stdout, stderr)
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 _prepare_mypy_cmd_options(self) -> 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])
if self.config_file:
mypy_cmd_options.append(f"--config-file={self.config_file}")
return mypy_cmd_options
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 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 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:
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)
# 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()
execution_path = Path(temp_dir.name)
with utils.cd(execution_path):
mypy_executor = MypyExecutor(
same_process=self.same_process,
execution_path=execution_path,
rootdir=rootdir,
environment_variables=self.environment_variables,
mypy_executable=mypy_executable,
)
output_checker = OutputChecker(
expect_fail=self.expect_fail, execution_path=execution_path, expected_output=self.expected_output
)
Runner(
files=self.files,
config=self.config,
main_file=execution_path / "main.py",
config_file=self.prepare_config_file(execution_path),
disable_cache=self.disable_cache,
mypy_executor=mypy_executor,
output_checker=output_checker,
test_only_local_stub=self.test_only_local_stub,
incremental_cache_dir=self.incremental_cache_dir,
).run()
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_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 = ReprEntry(
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[Path, str], Optional[int], str]:
return self.path, None, self.name
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/py.typed 0000664 0000000 0000000 00000000000 14731620246 0023360 0 ustar 00root root 0000000 0000000 pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/schema.json 0000664 0000000 0000000 00000011432 14731620246 0024027 0 ustar 00root root 0000000 0000000 {
"$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"
}
]
}
}
}
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/ 0000775 0000000 0000000 00000000000 14731620246 0023035 5 ustar 00root root 0000000 0000000 pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/reveal_type_hook.py 0000664 0000000 0000000 00000000542 14731620246 0026747 0 ustar 00root root 0000000 0000000 from 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})"
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test-assert-type.yml 0000664 0000000 0000000 00000000541 14731620246 0027015 0 ustar 00root root 0000000 0000000 - case: assert_type
main: |
from typing_extensions import assert_type
def x() -> int:
return 1
assert_type(x(), int)
- case: assert_type_error
mypy_config: |
warn_unused_ignores = true
main: |
from typing_extensions import assert_type
def x() -> int:
return 1
assert_type(x(), str) # type: ignore
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test-extension.yml 0000664 0000000 0000000 00000000267 14731620246 0026556 0 ustar 00root root 0000000 0000000 - 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]?"
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test-mypy-config.yml 0000664 0000000 0000000 00000000365 14731620246 0027002 0 ustar 00root root 0000000 0000000 # 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
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test-parametrized.yml 0000664 0000000 0000000 00000002672 14731620246 0027233 0 ustar 00root root 0000000 0000000 - 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
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test-paths-from-env.yml 0000664 0000000 0000000 00000000522 14731620246 0027402 0 ustar 00root root 0000000 0000000 - 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
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test-regex-assertions.yml 0000664 0000000 0000000 00000003332 14731620246 0030040 0 ustar 00root root 0000000 0000000 - 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.*
- case: regex_against_callable_comment
main: |
from typing import Set, Union
def foo(bar: str, ham: int = 42) -> Set[Union[str, int]]:
return {bar, ham}
reveal_type(foo) # NR: Revealed type is "def \(bar: builtins\.str, ham: builtins\.int =\) -> .*"
- case: regex_against_callable_out
regex: yes
main: |
from typing import Set, Union
def foo(bar: str, ham: int = 42) -> Set[Union[str, int]]:
return {bar, ham}
reveal_type(foo)
out: |
main:5: note: Revealed type is "def \(bar: builtins\.str, ham: builtins\.int =\) -> .*"
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test-simple-cases.yml 0000664 0000000 0000000 00000003421 14731620246 0027122 0 ustar 00root root 0000000 0000000 - 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
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test_configs/ 0000775 0000000 0000000 00000000000 14731620246 0025524 5 ustar 00root root 0000000 0000000 pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test_configs/mypy1.ini 0000664 0000000 0000000 00000000035 14731620246 0027302 0 ustar 00root root 0000000 0000000 [mypy]
show_traceback = true
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test_configs/mypy2.ini 0000664 0000000 0000000 00000000010 14731620246 0027274 0 ustar 00root root 0000000 0000000 # Empty
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test_configs/pyproject1.toml 0000664 0000000 0000000 00000000275 14731620246 0030525 0 ustar 00root root 0000000 0000000 # 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'
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test_configs/pyproject2.toml 0000664 0000000 0000000 00000000061 14731620246 0030517 0 ustar 00root root 0000000 0000000 # This file has no `[tool.mypy]` existing config
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test_configs/pyproject3.toml 0000664 0000000 0000000 00000000347 14731620246 0030527 0 ustar 00root root 0000000 0000000 # 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
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test_configs/setup1.cfg 0000664 0000000 0000000 00000000035 14731620246 0027424 0 ustar 00root root 0000000 0000000 [mypy]
show_traceback = true
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test_configs/setup2.cfg 0000664 0000000 0000000 00000000010 14731620246 0027416 0 ustar 00root root 0000000 0000000 # Empty
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py 0000664 0000000 0000000 00000006405 14731620246 0032644 0 ustar 00root root 0000000 0000000 from 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
""",
)
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test_explicit_configs.py 0000664 0000000 0000000 00000002367 14731620246 0030007 0 ustar 00root root 0000000 0000000 import 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,
]
)
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test_input_schema.py 0000664 0000000 0000000 00000003167 14731620246 0027134 0 ustar 00root root 0000000 0000000 import 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)"
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/tests/test_utils.py 0000664 0000000 0000000 00000020667 14731620246 0025621 0 ustar 00root root 0000000 0000000 # 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)
pytest-mypy-plugins-3.2.0/pytest_mypy_plugins/utils.py 0000664 0000000 0000000 00000030101 14731620246 0023400 0 ustar 00root root 0000000 0000000 # Borrowed from Pew.
# See https://github.com/berdario/pew/blob/master/pew/_utils.py#L82
import inspect
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 bool(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[A-Za-z]+):((?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)
pytest-mypy-plugins-3.2.0/release.sh 0000775 0000000 0000000 00000000602 14731620246 0017501 0 ustar 00root root 0000000 0000000 #!/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
pytest-mypy-plugins-3.2.0/requirements.txt 0000664 0000000 0000000 00000000126 14731620246 0021007 0 ustar 00root root 0000000 0000000 black
isort
types-decorator
types-PyYAML
types-setuptools
types-regex
mypy==1.13
-e .
pytest-mypy-plugins-3.2.0/setup.py 0000664 0000000 0000000 00000002667 14731620246 0017251 0 ustar 00root root 0000000 0000000 from 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.2.0",
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.9",
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.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
],
)
pytest-mypy-plugins-3.2.0/tox.ini 0000664 0000000 0000000 00000000122 14731620246 0017032 0 ustar 00root root 0000000 0000000 [testenv]
deps =
-rrequirements.txt
commands =
python -m pytest {posargs}