pax_global_header 0000666 0000000 0000000 00000000064 14602320613 0014507 g ustar 00root root 0000000 0000000 52 comment=1f5c62123c16c85f6f68cd02b8149f0605688ca6
typeddjango-pytest-mypy-plugins-be9eb51/ 0000775 0000000 0000000 00000000000 14602320613 0020516 5 ustar 00root root 0000000 0000000 typeddjango-pytest-mypy-plugins-be9eb51/.editorconfig 0000664 0000000 0000000 00000000452 14602320613 0023174 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
typeddjango-pytest-mypy-plugins-be9eb51/.github/ 0000775 0000000 0000000 00000000000 14602320613 0022056 5 ustar 00root root 0000000 0000000 typeddjango-pytest-mypy-plugins-be9eb51/.github/dependabot.yml 0000664 0000000 0000000 00000000426 14602320613 0024710 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
typeddjango-pytest-mypy-plugins-be9eb51/.github/workflows/ 0000775 0000000 0000000 00000000000 14602320613 0024113 5 ustar 00root root 0000000 0000000 typeddjango-pytest-mypy-plugins-be9eb51/.github/workflows/test.yml 0000664 0000000 0000000 00000002761 14602320613 0025623 0 ustar 00root root 0000000 0000000 name: 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/.gitignore 0000664 0000000 0000000 00000006315 14602320613 0022513 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
typeddjango-pytest-mypy-plugins-be9eb51/CHANGELOG.md 0000664 0000000 0000000 00000005302 14602320613 0022327 0 ustar 00root root 0000000 0000000 # 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/LICENSE 0000664 0000000 0000000 00000002036 14602320613 0021524 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. typeddjango-pytest-mypy-plugins-be9eb51/MANIFEST.in 0000664 0000000 0000000 00000000140 14602320613 0022247 0 ustar 00root root 0000000 0000000 include requirements.txt
include tox.ini
include pyproject.toml
graft pytest_mypy_plugins/tests
typeddjango-pytest-mypy-plugins-be9eb51/README.md 0000664 0000000 0000000 00000020777 14602320613 0022012 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
```
### 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.toml 0000664 0000000 0000000 00000001107 14602320613 0023431 0 ustar 00root root 0000000 0000000 [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/ 0000775 0000000 0000000 00000000000 14602320613 0024665 5 ustar 00root root 0000000 0000000 typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/__init__.py 0000664 0000000 0000000 00000000000 14602320613 0026764 0 ustar 00root root 0000000 0000000 typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/collect.py 0000664 0000000 0000000 00000020574 14602320613 0026674 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 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.py 0000664 0000000 0000000 00000004770 14602320613 0026677 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)
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/item.py 0000664 0000000 0000000 00000036501 14602320613 0026202 0 ustar 00root root 0000000 0000000 import 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.typed 0000664 0000000 0000000 00000000000 14602320613 0026352 0 ustar 00root root 0000000 0000000 typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/schema.json 0000664 0000000 0000000 00000011432 14602320613 0027021 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"
}
]
}
}
}
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/ 0000775 0000000 0000000 00000000000 14602320613 0026027 5 ustar 00root root 0000000 0000000 typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/reveal_type_hook.py 0000664 0000000 0000000 00000000542 14602320613 0031741 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})"
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test-extension.yml 0000664 0000000 0000000 00000000267 14602320613 0031550 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]?"
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test-mypy-config.yml 0000664 0000000 0000000 00000000365 14602320613 0031774 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
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test-parametrized.yml 0000664 0000000 0000000 00000002672 14602320613 0032225 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
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test-paths-from-env.yml 0000664 0000000 0000000 00000000522 14602320613 0032374 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
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test-regex-assertions.yml 0000664 0000000 0000000 00000002233 14602320613 0033031 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.*
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test-simple-cases.yml 0000664 0000000 0000000 00000003421 14602320613 0032114 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
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/ 0000775 0000000 0000000 00000000000 14602320613 0030516 5 ustar 00root root 0000000 0000000 typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/mypy1.ini 0000664 0000000 0000000 00000000035 14602320613 0032274 0 ustar 00root root 0000000 0000000 [mypy]
show_traceback = true
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/mypy2.ini 0000664 0000000 0000000 00000000010 14602320613 0032266 0 ustar 00root root 0000000 0000000 # Empty
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/pyproject1.toml 0000664 0000000 0000000 00000000275 14602320613 0033517 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'
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/pyproject2.toml 0000664 0000000 0000000 00000000061 14602320613 0033511 0 ustar 00root root 0000000 0000000 # This file has no `[tool.mypy]` existing config
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/pyproject3.toml 0000664 0000000 0000000 00000000347 14602320613 0033521 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
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/setup1.cfg 0000664 0000000 0000000 00000000035 14602320613 0032416 0 ustar 00root root 0000000 0000000 [mypy]
show_traceback = true
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs/setup2.cfg 0000664 0000000 0000000 00000000010 14602320613 0032410 0 ustar 00root root 0000000 0000000 # Empty
test_join_toml_configs.py 0000664 0000000 0000000 00000006405 14602320613 0035557 0 ustar 00root root 0000000 0000000 typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_configs 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
""",
)
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_explicit_configs.py 0000664 0000000 0000000 00000002367 14602320613 0033001 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,
]
)
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_input_schema.py 0000664 0000000 0000000 00000003167 14602320613 0032126 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)"
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/tests/test_utils.py 0000664 0000000 0000000 00000020667 14602320613 0030613 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)
typeddjango-pytest-mypy-plugins-be9eb51/pytest_mypy_plugins/utils.py 0000664 0000000 0000000 00000030120 14602320613 0026373 0 ustar 00root root 0000000 0000000 # 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.sh 0000775 0000000 0000000 00000000602 14602320613 0022473 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
typeddjango-pytest-mypy-plugins-be9eb51/requirements.txt 0000664 0000000 0000000 00000000077 14602320613 0024006 0 ustar 00root root 0000000 0000000 black
isort
types-decorator
types-PyYAML
types-setuptools
-e .
typeddjango-pytest-mypy-plugins-be9eb51/setup.py 0000664 0000000 0000000 00000002666 14602320613 0022242 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.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.ini 0000664 0000000 0000000 00000000122 14602320613 0022024 0 ustar 00root root 0000000 0000000 [testenv]
deps =
-rrequirements.txt
commands =
python -m pytest {posargs}