pytest-revealtype-injector 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_revealtype_injector-0.1.0/.gitignore +119 -0
- pytest_revealtype_injector-0.1.0/COPYING +6 -0
- pytest_revealtype_injector-0.1.0/COPYING.mit +19 -0
- pytest_revealtype_injector-0.1.0/PKG-INFO +82 -0
- pytest_revealtype_injector-0.1.0/README.md +54 -0
- pytest_revealtype_injector-0.1.0/pyproject.toml +132 -0
- pytest_revealtype_injector-0.1.0/src/pytest_revealtype_injector/__init__.py +3 -0
- pytest_revealtype_injector-0.1.0/src/pytest_revealtype_injector/adapter/__init__.py +13 -0
- pytest_revealtype_injector-0.1.0/src/pytest_revealtype_injector/adapter/mypy_.py +217 -0
- pytest_revealtype_injector-0.1.0/src/pytest_revealtype_injector/adapter/pyright_.py +124 -0
- pytest_revealtype_injector-0.1.0/src/pytest_revealtype_injector/hooks.py +74 -0
- pytest_revealtype_injector-0.1.0/src/pytest_revealtype_injector/main.py +140 -0
- pytest_revealtype_injector-0.1.0/src/pytest_revealtype_injector/models.py +108 -0
- pytest_revealtype_injector-0.1.0/src/pytest_revealtype_injector/plugin.py +6 -0
- pytest_revealtype_injector-0.1.0/src/pytest_revealtype_injector/py.typed +0 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.pytest_cache/
|
|
51
|
+
cover/
|
|
52
|
+
|
|
53
|
+
# Django stuff:
|
|
54
|
+
*.log
|
|
55
|
+
|
|
56
|
+
# Flask stuff:
|
|
57
|
+
instance/
|
|
58
|
+
.webassets-cache
|
|
59
|
+
|
|
60
|
+
# Scrapy stuff:
|
|
61
|
+
.scrapy
|
|
62
|
+
|
|
63
|
+
# Sphinx documentation
|
|
64
|
+
docs/_build/
|
|
65
|
+
|
|
66
|
+
# PyBuilder
|
|
67
|
+
.pybuilder/
|
|
68
|
+
target/
|
|
69
|
+
|
|
70
|
+
# Jupyter Notebook
|
|
71
|
+
.ipynb_checkpoints
|
|
72
|
+
|
|
73
|
+
# IPython
|
|
74
|
+
profile_default/
|
|
75
|
+
ipython_config.py
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# pdm
|
|
79
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
80
|
+
#pdm.lock
|
|
81
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
82
|
+
# in version control.
|
|
83
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
84
|
+
.pdm.toml
|
|
85
|
+
.pdm-python
|
|
86
|
+
.pdm-build/
|
|
87
|
+
|
|
88
|
+
# Environments
|
|
89
|
+
.env
|
|
90
|
+
.venv
|
|
91
|
+
env/
|
|
92
|
+
venv/
|
|
93
|
+
ENV/
|
|
94
|
+
env.bak/
|
|
95
|
+
venv.bak/
|
|
96
|
+
|
|
97
|
+
# mypy
|
|
98
|
+
.mypy_cache/
|
|
99
|
+
.dmypy.json
|
|
100
|
+
dmypy.json
|
|
101
|
+
|
|
102
|
+
# Pyre type checker
|
|
103
|
+
.pyre/
|
|
104
|
+
|
|
105
|
+
# pytype static type analyzer
|
|
106
|
+
.pytype/
|
|
107
|
+
|
|
108
|
+
# ruff formatter
|
|
109
|
+
.ruff_cache/
|
|
110
|
+
|
|
111
|
+
# Local utility scripts
|
|
112
|
+
/*.py
|
|
113
|
+
|
|
114
|
+
# Editor / IDE
|
|
115
|
+
*~
|
|
116
|
+
*.bak
|
|
117
|
+
.*.sw?
|
|
118
|
+
.vscode/
|
|
119
|
+
.idea/
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pytest-revealtype-injector is released under MIT license (see COPYING.mit).
|
|
2
|
+
|
|
3
|
+
Original source code comes from part of types-lxml project, which is
|
|
4
|
+
release under Apache-2.0 license. But as the sole author of all source
|
|
5
|
+
code inside this repository, it is at my own discretion to follow pytest
|
|
6
|
+
license because this project is taking shape as a pytest plugin.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2023-2024 Abel Cheung
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pytest-revealtype-injector
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pytest plugin for replacing reveal_type() calls inside test functions with static and runtime type checking result comparison, for confirming type annotation validity.
|
|
5
|
+
Project-URL: homepage, https://github.com/abelcheung/pytest-revealtype-injector
|
|
6
|
+
Author-email: Abel Cheung <abelcheung@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: annotation,dynamic-typing,pytest,reveal_type,static-typing,stub,stubs,type-checking,types,typing
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Framework :: Pytest
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: mypy>=1.11.2
|
|
24
|
+
Requires-Dist: pyright~=1.1
|
|
25
|
+
Requires-Dist: pytest>=7.0
|
|
26
|
+
Requires-Dist: typeguard~=4.3
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
`pytest-revealtype-injector` is a `pytest` plugin for replacing [`reveal_type()`](https://docs.python.org/3/library/typing.html#typing.reveal_type) calls inside test functions as something more sophisticated. It does the following tasks in parallel:
|
|
30
|
+
|
|
31
|
+
- Launch external static type checkers (`pyright` and `mypy`) and store `reveal_type` results.
|
|
32
|
+
- Use [`typeguard`](https://github.com/agronholm/typeguard) to verify the aforementioned static type checker result _really_ matches runtime code result.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
In short: install this plugin, create test functions which calls `reveal_type()` with variable or function return result, done.
|
|
37
|
+
|
|
38
|
+
### The longer story
|
|
39
|
+
|
|
40
|
+
This plugin would be automatically enabled when launching `pytest`.
|
|
41
|
+
|
|
42
|
+
For using `reveal_type()` inside tests, there is no boiler plate code involved. Import `reveal_type` normally, like:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from typing import reveal_type
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Just importing `typing` module is fine too (or import `typing_extensions` for Python 3.10, because `reveal_type()` is only available officially since 3.11):
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import typing
|
|
52
|
+
|
|
53
|
+
def test_something():
|
|
54
|
+
x: str = 1 # type: ignore # pyright: ignore
|
|
55
|
+
typing.reveal_type(x) # typeguard fails here
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Since this plugin scans for `reveal_type()` for replacement under carpet, even `import ... as ...` syntax works too:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import typing as typ # or...
|
|
62
|
+
from typing import reveal_type as rt
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Limitations
|
|
66
|
+
|
|
67
|
+
But there are 2 caveats.
|
|
68
|
+
|
|
69
|
+
1. This plugin only searches for global import in test files, so local import inside test function doesn't work. That means following code doesn't utilize this plugin at all:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
def test_something():
|
|
73
|
+
from typing import reveal_type
|
|
74
|
+
x = 1
|
|
75
|
+
reveal_type(x) # calls vanilla reveal_type()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
2. `reveal_type()` calls have to stay in a single line, without anything else. This limitation comes from using [`eval` mode in AST parsing](https://docs.python.org/3/library/ast.html#ast.Expression).
|
|
79
|
+
|
|
80
|
+
## History
|
|
81
|
+
|
|
82
|
+
This pytest plugin starts its life as part of testsuite related utilities within [`types-lxml`](https://github.com/abelcheung/types-lxml). As `lxml` is a `cython` project and probably never incorporate inline python annotation in future, there is need to compare runtime result to static type checker output for discrepancy. As time goes by, it starts to make sense to manage as an independent project.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
`pytest-revealtype-injector` is a `pytest` plugin for replacing [`reveal_type()`](https://docs.python.org/3/library/typing.html#typing.reveal_type) calls inside test functions as something more sophisticated. It does the following tasks in parallel:
|
|
2
|
+
|
|
3
|
+
- Launch external static type checkers (`pyright` and `mypy`) and store `reveal_type` results.
|
|
4
|
+
- Use [`typeguard`](https://github.com/agronholm/typeguard) to verify the aforementioned static type checker result _really_ matches runtime code result.
|
|
5
|
+
|
|
6
|
+
## Usage
|
|
7
|
+
|
|
8
|
+
In short: install this plugin, create test functions which calls `reveal_type()` with variable or function return result, done.
|
|
9
|
+
|
|
10
|
+
### The longer story
|
|
11
|
+
|
|
12
|
+
This plugin would be automatically enabled when launching `pytest`.
|
|
13
|
+
|
|
14
|
+
For using `reveal_type()` inside tests, there is no boiler plate code involved. Import `reveal_type` normally, like:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from typing import reveal_type
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Just importing `typing` module is fine too (or import `typing_extensions` for Python 3.10, because `reveal_type()` is only available officially since 3.11):
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import typing
|
|
24
|
+
|
|
25
|
+
def test_something():
|
|
26
|
+
x: str = 1 # type: ignore # pyright: ignore
|
|
27
|
+
typing.reveal_type(x) # typeguard fails here
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Since this plugin scans for `reveal_type()` for replacement under carpet, even `import ... as ...` syntax works too:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import typing as typ # or...
|
|
34
|
+
from typing import reveal_type as rt
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Limitations
|
|
38
|
+
|
|
39
|
+
But there are 2 caveats.
|
|
40
|
+
|
|
41
|
+
1. This plugin only searches for global import in test files, so local import inside test function doesn't work. That means following code doesn't utilize this plugin at all:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
def test_something():
|
|
45
|
+
from typing import reveal_type
|
|
46
|
+
x = 1
|
|
47
|
+
reveal_type(x) # calls vanilla reveal_type()
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
2. `reveal_type()` calls have to stay in a single line, without anything else. This limitation comes from using [`eval` mode in AST parsing](https://docs.python.org/3/library/ast.html#ast.Expression).
|
|
51
|
+
|
|
52
|
+
## History
|
|
53
|
+
|
|
54
|
+
This pytest plugin starts its life as part of testsuite related utilities within [`types-lxml`](https://github.com/abelcheung/types-lxml). As `lxml` is a `cython` project and probably never incorporate inline python annotation in future, there is need to compare runtime result to static type checker output for discrepancy. As time goes by, it starts to make sense to manage as an independent project.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#:schema https://json.schemastore.org/pyproject.json
|
|
2
|
+
|
|
3
|
+
[build-system]
|
|
4
|
+
requires = ['hatchling']
|
|
5
|
+
build-backend = 'hatchling.build'
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = 'pytest-revealtype-injector'
|
|
9
|
+
dynamic = ['version']
|
|
10
|
+
description = """Pytest plugin for replacing reveal_type() calls inside
|
|
11
|
+
test functions with static and runtime type checking result comparison,
|
|
12
|
+
for confirming type annotation validity."""
|
|
13
|
+
readme = 'README.md'
|
|
14
|
+
requires-python = '>=3.10'
|
|
15
|
+
license = {text = 'MIT'}
|
|
16
|
+
dependencies = [
|
|
17
|
+
'mypy >= 1.11.2',
|
|
18
|
+
'pyright ~= 1.1',
|
|
19
|
+
'pytest >= 7.0',
|
|
20
|
+
'typeguard ~= 4.3'
|
|
21
|
+
]
|
|
22
|
+
keywords = [
|
|
23
|
+
'pytest',
|
|
24
|
+
'typing',
|
|
25
|
+
'types',
|
|
26
|
+
'stub',
|
|
27
|
+
'stubs',
|
|
28
|
+
'static-typing',
|
|
29
|
+
'dynamic-typing',
|
|
30
|
+
'type-checking',
|
|
31
|
+
'annotation',
|
|
32
|
+
'reveal_type',
|
|
33
|
+
]
|
|
34
|
+
authors = [
|
|
35
|
+
{ name = 'Abel Cheung', email = 'abelcheung@gmail.com' }
|
|
36
|
+
]
|
|
37
|
+
classifiers = [
|
|
38
|
+
'Development Status :: 4 - Beta',
|
|
39
|
+
'Programming Language :: Python',
|
|
40
|
+
'Intended Audience :: Developers',
|
|
41
|
+
'Framework :: Pytest',
|
|
42
|
+
'Programming Language :: Python :: 3',
|
|
43
|
+
'Programming Language :: Python :: 3 :: Only',
|
|
44
|
+
'Programming Language :: Python :: 3.10',
|
|
45
|
+
'Programming Language :: Python :: 3.11',
|
|
46
|
+
'Programming Language :: Python :: 3.12',
|
|
47
|
+
'Programming Language :: Python :: 3.13',
|
|
48
|
+
'License :: OSI Approved :: MIT License',
|
|
49
|
+
'Topic :: Software Development :: Testing',
|
|
50
|
+
'Typing :: Typed',
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[project.urls]
|
|
54
|
+
homepage = 'https://github.com/abelcheung/pytest-revealtype-injector'
|
|
55
|
+
|
|
56
|
+
[project.entry-points.pytest11]
|
|
57
|
+
pytest-revealtype-injector = "pytest_revealtype_injector.plugin"
|
|
58
|
+
|
|
59
|
+
[tool.flit.module]
|
|
60
|
+
name = 'pytest_revealtype_injector'
|
|
61
|
+
|
|
62
|
+
[tool.hatch.version]
|
|
63
|
+
path = 'src/pytest_revealtype_injector/__init__.py'
|
|
64
|
+
|
|
65
|
+
[tool.hatch.build.targets.sdist]
|
|
66
|
+
exclude = [
|
|
67
|
+
'**/.*',
|
|
68
|
+
'CHANGELOG.md',
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.hatch.build.targets.wheel]
|
|
72
|
+
packages = ["src/pytest_revealtype_injector"]
|
|
73
|
+
|
|
74
|
+
[tool.pyright]
|
|
75
|
+
typeCheckingMode = 'strict'
|
|
76
|
+
enableTypeIgnoreComments = false
|
|
77
|
+
deprecateTypingAliases = true
|
|
78
|
+
|
|
79
|
+
[tool.mypy]
|
|
80
|
+
mypy_path = "$MYPY_CONFIG_FILE_DIR/src"
|
|
81
|
+
packages = "pytest_revealtype_injector"
|
|
82
|
+
strict = true
|
|
83
|
+
|
|
84
|
+
[tool.ruff]
|
|
85
|
+
target-version = "py312"
|
|
86
|
+
|
|
87
|
+
[tool.ruff.format]
|
|
88
|
+
preview = true
|
|
89
|
+
|
|
90
|
+
[tool.ruff.lint]
|
|
91
|
+
select = [
|
|
92
|
+
'E',
|
|
93
|
+
'F',
|
|
94
|
+
'I',
|
|
95
|
+
]
|
|
96
|
+
task-tags = [
|
|
97
|
+
"BUG",
|
|
98
|
+
"DEBUG",
|
|
99
|
+
"FIX",
|
|
100
|
+
"FIXME",
|
|
101
|
+
"HACK",
|
|
102
|
+
"IDEA",
|
|
103
|
+
"NOTE",
|
|
104
|
+
"OPTIMIZE",
|
|
105
|
+
"REVIEW",
|
|
106
|
+
"TODO",
|
|
107
|
+
"UGLY",
|
|
108
|
+
"XXX",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
[tool.ruff.lint.isort]
|
|
112
|
+
combine-as-imports = true
|
|
113
|
+
|
|
114
|
+
[tool.pytest.ini_options]
|
|
115
|
+
minversion = "7.0"
|
|
116
|
+
addopts = [
|
|
117
|
+
"--tb=short",
|
|
118
|
+
"--import-mode=importlib",
|
|
119
|
+
]
|
|
120
|
+
markers = [
|
|
121
|
+
"slow: marks tests as slow",
|
|
122
|
+
]
|
|
123
|
+
testpaths = [
|
|
124
|
+
"tests",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
# We only use version determination logic from python-semantic-release,
|
|
128
|
+
# and never does any permanent change with it
|
|
129
|
+
[tool.semantic_release]
|
|
130
|
+
version_variables = ['src/pytest_revealtype_injector/__init__.py:__version__']
|
|
131
|
+
major_on_zero = false # switch on for 1.0.0
|
|
132
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..models import TypeCheckerAdapter
|
|
4
|
+
from . import mypy_, pyright_
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Hardcode will do for now, it's not like we're going to have more
|
|
8
|
+
# adapters soon. Pyre and PyType are not there yet.
|
|
9
|
+
def discovery() -> set[TypeCheckerAdapter]:
|
|
10
|
+
return {
|
|
11
|
+
pyright_.adapter,
|
|
12
|
+
mypy_.adapter,
|
|
13
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import importlib
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import pathlib
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import (
|
|
8
|
+
Iterable,
|
|
9
|
+
)
|
|
10
|
+
from typing import (
|
|
11
|
+
Any,
|
|
12
|
+
ForwardRef,
|
|
13
|
+
Literal,
|
|
14
|
+
TypedDict,
|
|
15
|
+
cast,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
import mypy.api
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from ..models import (
|
|
22
|
+
FilePos,
|
|
23
|
+
NameCollectorBase,
|
|
24
|
+
TypeCheckerAdapter,
|
|
25
|
+
TypeCheckerError,
|
|
26
|
+
VarType,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_logger = logging.getLogger(__name__)
|
|
30
|
+
_logger.setLevel(logging.INFO)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _MypyDiagObj(TypedDict):
|
|
34
|
+
file: str
|
|
35
|
+
line: int
|
|
36
|
+
column: int
|
|
37
|
+
message: str
|
|
38
|
+
hint: str | None
|
|
39
|
+
code: str
|
|
40
|
+
severity: Literal["note", "warning", "error"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _NameCollector(NameCollectorBase):
|
|
44
|
+
def visit_Attribute(self, node: ast.Attribute) -> ast.expr:
|
|
45
|
+
prefix = ast.unparse(node.value)
|
|
46
|
+
name = node.attr
|
|
47
|
+
|
|
48
|
+
setattr(node.value, "is_parent", True)
|
|
49
|
+
if not hasattr(node, "is_parent"): # Outmost attribute node
|
|
50
|
+
try:
|
|
51
|
+
_ = importlib.import_module(prefix)
|
|
52
|
+
except ModuleNotFoundError:
|
|
53
|
+
# Mypy resolve names according to external stub if
|
|
54
|
+
# available. For example, _ElementTree is determined
|
|
55
|
+
# as lxml.etree._element._ElementTree, which doesn't
|
|
56
|
+
# exist in runtime. Try to resolve bare names
|
|
57
|
+
# instead, which rely on runtime tests importing
|
|
58
|
+
# them properly before resolving.
|
|
59
|
+
try:
|
|
60
|
+
eval(name, self._globalns, self._localns | self.collected)
|
|
61
|
+
except NameError as e:
|
|
62
|
+
raise NameError(f'Cannot resolve "{prefix}" or "{name}"') from e
|
|
63
|
+
else:
|
|
64
|
+
self.modified = True
|
|
65
|
+
return ast.Name(id=name, ctx=node.ctx)
|
|
66
|
+
|
|
67
|
+
_ = self.visit(node.value)
|
|
68
|
+
|
|
69
|
+
if resolved := getattr(self.collected[prefix], name, False):
|
|
70
|
+
self.collected[ast.unparse(node)] = resolved
|
|
71
|
+
return node
|
|
72
|
+
|
|
73
|
+
# For class defined in local scope, mypy just prepends test
|
|
74
|
+
# module name to class name. Of course concerned class does
|
|
75
|
+
# not exist directly under test module. Use bare name here.
|
|
76
|
+
try:
|
|
77
|
+
eval(name, self._globalns, self._localns | self.collected)
|
|
78
|
+
except NameError:
|
|
79
|
+
raise
|
|
80
|
+
else:
|
|
81
|
+
self.modified = True
|
|
82
|
+
return ast.Name(id=name, ctx=node.ctx)
|
|
83
|
+
|
|
84
|
+
# Mypy usually dumps full inferred type with module name,
|
|
85
|
+
# but with a few exceptions (like tuple, Union).
|
|
86
|
+
# visit_Attribute can ultimately recurse into visit_Name
|
|
87
|
+
# as well
|
|
88
|
+
def visit_Name(self, node: ast.Name) -> ast.Name:
|
|
89
|
+
name = node.id
|
|
90
|
+
try:
|
|
91
|
+
eval(name, self._globalns, self._localns | self.collected)
|
|
92
|
+
except NameError:
|
|
93
|
+
pass
|
|
94
|
+
else:
|
|
95
|
+
return node
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
mod = importlib.import_module(name)
|
|
99
|
+
except ModuleNotFoundError:
|
|
100
|
+
pass
|
|
101
|
+
else:
|
|
102
|
+
self.collected[name] = mod
|
|
103
|
+
return node
|
|
104
|
+
|
|
105
|
+
if hasattr(self.collected["typing"], name):
|
|
106
|
+
self.collected[name] = getattr(self.collected["typing"], name)
|
|
107
|
+
return node
|
|
108
|
+
|
|
109
|
+
raise NameError(f'Cannot resolve "{name}"')
|
|
110
|
+
|
|
111
|
+
# For class defined inside local function scope, mypy outputs
|
|
112
|
+
# something like "test_elem_class_lookup.FooClass@97".
|
|
113
|
+
# Return only the left operand after processing.
|
|
114
|
+
def visit_BinOp(self, node: ast.BinOp) -> ast.expr:
|
|
115
|
+
if isinstance(node.op, ast.MatMult) and isinstance(node.right, ast.Constant):
|
|
116
|
+
# Mypy disallows returning Any
|
|
117
|
+
return cast("ast.expr", self.visit(node.left))
|
|
118
|
+
# For expression that haven't been accounted for, just don't
|
|
119
|
+
# process and allow name resolution to fail
|
|
120
|
+
return node
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _MypyAdapter(TypeCheckerAdapter):
|
|
124
|
+
id = "mypy"
|
|
125
|
+
typechecker_result = {}
|
|
126
|
+
_type_mesg_re = re.compile(r'^Revealed type is "(?P<type>.+?)"$')
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def run_typechecker_on(cls, paths: Iterable[pathlib.Path]) -> None:
|
|
130
|
+
mypy_args = [
|
|
131
|
+
"--output=json",
|
|
132
|
+
]
|
|
133
|
+
if cls.config_file is not None:
|
|
134
|
+
cfg_str = str(cls.config_file)
|
|
135
|
+
if cfg_str == ".": # see set_config_file() below
|
|
136
|
+
cfg_str = ""
|
|
137
|
+
mypy_args.append(f"--config-file={cfg_str}")
|
|
138
|
+
|
|
139
|
+
mypy_args.extend(str(p) for p in paths)
|
|
140
|
+
|
|
141
|
+
stdout, stderr, returncode = mypy.api.run(mypy_args)
|
|
142
|
+
|
|
143
|
+
# fatal error, before evaluation happens
|
|
144
|
+
# mypy prints text output to stderr, not json
|
|
145
|
+
if stderr:
|
|
146
|
+
raise TypeCheckerError(stderr, None, None)
|
|
147
|
+
|
|
148
|
+
# So-called mypy json output is merely a line-by-line
|
|
149
|
+
# transformation of plain text output into json object
|
|
150
|
+
for line in stdout.splitlines():
|
|
151
|
+
# TODO Mypy json schema validation
|
|
152
|
+
diag = cast(_MypyDiagObj, json.loads(line))
|
|
153
|
+
filename = pathlib.Path(diag["file"]).name
|
|
154
|
+
pos = FilePos(filename, diag["line"])
|
|
155
|
+
if diag["severity"] != "note":
|
|
156
|
+
raise TypeCheckerError(
|
|
157
|
+
"Mypy {} with exit code {}: {}".format(
|
|
158
|
+
diag["severity"], returncode, diag["message"]
|
|
159
|
+
),
|
|
160
|
+
diag["file"],
|
|
161
|
+
diag["line"],
|
|
162
|
+
)
|
|
163
|
+
if (m := cls._type_mesg_re.match(diag["message"])) is None:
|
|
164
|
+
continue
|
|
165
|
+
# Mypy can insert extra character into expression so that it
|
|
166
|
+
# becomes invalid and unparsable. 0.9x days there
|
|
167
|
+
# was '*', and now '?' (and '=' for typeddict too).
|
|
168
|
+
# Try stripping those character and pray we get something
|
|
169
|
+
# usable for evaluation
|
|
170
|
+
expression = m["type"].translate({ord(c): None for c in "*?="})
|
|
171
|
+
# Unlike pyright, mypy output doesn't contain variable name
|
|
172
|
+
cls.typechecker_result[pos] = VarType(None, ForwardRef(expression))
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def create_collector(
|
|
176
|
+
cls, globalns: dict[str, Any], localns: dict[str, Any]
|
|
177
|
+
) -> _NameCollector:
|
|
178
|
+
return _NameCollector(globalns, localns)
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def set_config_file(cls, config: pytest.Config) -> None:
|
|
182
|
+
# Mypy doesn't have a default config file
|
|
183
|
+
if (path_str := config.option.revealtype_mypy_config) is None:
|
|
184
|
+
_logger.info("Using default mypy configuration")
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
# HACK: when path_str is empty string, use no config file
|
|
188
|
+
# ('mypy --config-file=')
|
|
189
|
+
# Take advantage of pathlib.Path() behavior that empty string
|
|
190
|
+
# is treated as current directory, which is not a valid
|
|
191
|
+
# config file name, while satisfying typing constraint
|
|
192
|
+
if not path_str:
|
|
193
|
+
cls.config_file = pathlib.Path()
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
relpath = pathlib.Path(path_str)
|
|
197
|
+
if relpath.is_absolute():
|
|
198
|
+
raise ValueError(f"Path '{path_str}' must be relative to pytest rootdir")
|
|
199
|
+
result = (config.rootpath / relpath).resolve()
|
|
200
|
+
if not result.exists():
|
|
201
|
+
raise FileNotFoundError(f"Path '{result}' not found")
|
|
202
|
+
|
|
203
|
+
_logger.info(f"Using mypy configuration file at {result}")
|
|
204
|
+
cls.config_file = result
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def add_pytest_option(group: pytest.OptionGroup) -> None:
|
|
208
|
+
group.addoption(
|
|
209
|
+
"--revealtype-mypy-config",
|
|
210
|
+
type=str,
|
|
211
|
+
default=None,
|
|
212
|
+
help="Mypy configuration file, path is relative to pytest rootdir. "
|
|
213
|
+
"If unspecified, use mypy default behavior",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
adapter = _MypyAdapter()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import pathlib
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import (
|
|
9
|
+
Iterable,
|
|
10
|
+
)
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
ForwardRef,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from ..models import (
|
|
19
|
+
FilePos,
|
|
20
|
+
NameCollectorBase,
|
|
21
|
+
TypeCheckerAdapter,
|
|
22
|
+
TypeCheckerError,
|
|
23
|
+
VarType,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_logger = logging.getLogger(__name__)
|
|
27
|
+
_logger.setLevel(logging.INFO)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _NameCollector(NameCollectorBase):
|
|
31
|
+
# Pyright inferred type results always contain bare names only,
|
|
32
|
+
# so don't need to bother with visit_Attribute()
|
|
33
|
+
def visit_Name(self, node: ast.Name) -> ast.Name:
|
|
34
|
+
name = node.id
|
|
35
|
+
try:
|
|
36
|
+
eval(name, self._globalns, self._localns | self.collected)
|
|
37
|
+
except NameError:
|
|
38
|
+
for m in ("typing", "typing_extensions"):
|
|
39
|
+
if hasattr(self.collected[m], name):
|
|
40
|
+
self.collected[name] = getattr(self.collected[m], name)
|
|
41
|
+
return node
|
|
42
|
+
raise
|
|
43
|
+
return node
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _PyrightAdapter(TypeCheckerAdapter):
|
|
47
|
+
id = "pyright"
|
|
48
|
+
typechecker_result = {}
|
|
49
|
+
_type_mesg_re = re.compile('^Type of "(?P<var>.+?)" is "(?P<type>.+?)"$')
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def run_typechecker_on(cls, paths: Iterable[pathlib.Path]) -> None:
|
|
53
|
+
cmd: list[str] = []
|
|
54
|
+
if shutil.which("pyright") is not None:
|
|
55
|
+
cmd.append("pyright")
|
|
56
|
+
elif shutil.which("npx") is not None:
|
|
57
|
+
cmd.extend(["npx", "pyright"])
|
|
58
|
+
else:
|
|
59
|
+
raise FileNotFoundError("Pyright is required to run test suite")
|
|
60
|
+
|
|
61
|
+
cmd.append("--outputjson")
|
|
62
|
+
if cls.config_file is not None:
|
|
63
|
+
cmd.extend(["--project", str(cls.config_file)])
|
|
64
|
+
cmd.extend(str(p) for p in paths)
|
|
65
|
+
|
|
66
|
+
proc = subprocess.run(cmd, capture_output=True)
|
|
67
|
+
if len(proc.stderr):
|
|
68
|
+
raise TypeCheckerError(proc.stderr.decode(), None, None)
|
|
69
|
+
|
|
70
|
+
# TODO Pyright json schema validation
|
|
71
|
+
report = json.loads(proc.stdout)
|
|
72
|
+
if proc.returncode:
|
|
73
|
+
for diag in report["generalDiagnostics"]:
|
|
74
|
+
if diag["severity"] != "error":
|
|
75
|
+
continue
|
|
76
|
+
# Pyright report lineno is 0-based,
|
|
77
|
+
# OTOH python frame lineno is 1-based
|
|
78
|
+
lineno = diag["range"]["start"]["line"] + 1
|
|
79
|
+
filename = pathlib.Path(diag["file"]).name
|
|
80
|
+
raise TypeCheckerError(diag["message"], filename, lineno)
|
|
81
|
+
for diag in report["generalDiagnostics"]:
|
|
82
|
+
if diag["severity"] != "information":
|
|
83
|
+
continue
|
|
84
|
+
lineno = diag["range"]["start"]["line"] + 1
|
|
85
|
+
filename = pathlib.Path(diag["file"]).name
|
|
86
|
+
if (m := cls._type_mesg_re.match(diag["message"])) is None:
|
|
87
|
+
continue
|
|
88
|
+
pos = FilePos(filename, lineno)
|
|
89
|
+
cls.typechecker_result[pos] = VarType(m["var"], ForwardRef(m["type"]))
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def create_collector(
|
|
93
|
+
cls, globalns: dict[str, Any], localns: dict[str, Any]
|
|
94
|
+
) -> _NameCollector:
|
|
95
|
+
return _NameCollector(globalns, localns)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def set_config_file(cls, config: pytest.Config) -> None:
|
|
99
|
+
if (path_str := config.option.revealtype_pyright_config) is None:
|
|
100
|
+
_logger.info("Using default pyright configuration")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
relpath = pathlib.Path(path_str)
|
|
104
|
+
if relpath.is_absolute():
|
|
105
|
+
raise ValueError(f"Path '{path_str}' must be relative to pytest rootdir")
|
|
106
|
+
result = (config.rootpath / relpath).resolve()
|
|
107
|
+
if not result.exists():
|
|
108
|
+
raise FileNotFoundError(f"Path '{result}' not found")
|
|
109
|
+
|
|
110
|
+
_logger.info(f"Using pyright configuration file at {result}")
|
|
111
|
+
cls.config_file = result
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def add_pytest_option(group: pytest.OptionGroup) -> None:
|
|
115
|
+
group.addoption(
|
|
116
|
+
"--revealtype-pyright-config",
|
|
117
|
+
type=str,
|
|
118
|
+
default=None,
|
|
119
|
+
help="Pyright configuration file, path is relative to pytest rootdir. "
|
|
120
|
+
"If unspecified, use pyright default behavior",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
adapter = _PyrightAdapter()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from . import adapter
|
|
9
|
+
from .main import revealtype_injector
|
|
10
|
+
|
|
11
|
+
_logger = logging.getLogger(__name__)
|
|
12
|
+
_logger.setLevel(logging.INFO)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> None:
|
|
16
|
+
assert pyfuncitem.module is not None
|
|
17
|
+
for name in dir(pyfuncitem.module):
|
|
18
|
+
if name.startswith("__") or name.startswith("@py"):
|
|
19
|
+
continue
|
|
20
|
+
|
|
21
|
+
item = getattr(pyfuncitem.module, name)
|
|
22
|
+
if inspect.isfunction(item):
|
|
23
|
+
if item.__name__ == "reveal_type" and item.__module__ in {
|
|
24
|
+
"typing",
|
|
25
|
+
"typing_extensions",
|
|
26
|
+
}:
|
|
27
|
+
setattr(pyfuncitem.module, name, revealtype_injector)
|
|
28
|
+
_logger.info(
|
|
29
|
+
f"Replaced {name}() from global import with {revealtype_injector}"
|
|
30
|
+
)
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
if inspect.ismodule(item):
|
|
34
|
+
if item.__name__ not in {"typing", "typing_extensions"}:
|
|
35
|
+
continue
|
|
36
|
+
assert hasattr(item, "reveal_type")
|
|
37
|
+
setattr(item, "reveal_type", revealtype_injector)
|
|
38
|
+
_logger.info(f"Replaced {name}.reveal_type() with {revealtype_injector}")
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pytest_collection_finish(session: pytest.Session) -> None:
|
|
43
|
+
files = {i.path for i in session.items}
|
|
44
|
+
for adp in adapter.discovery():
|
|
45
|
+
if adp.enabled:
|
|
46
|
+
adp.run_typechecker_on(files)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
50
|
+
group = parser.getgroup(
|
|
51
|
+
"revealtype-injector",
|
|
52
|
+
description="Type checker related options for revealtype-injector",
|
|
53
|
+
)
|
|
54
|
+
adapters = adapter.discovery()
|
|
55
|
+
choices = tuple(adp.id for adp in adapters)
|
|
56
|
+
group.addoption(
|
|
57
|
+
"--revealtype-disable-adapter",
|
|
58
|
+
type=str,
|
|
59
|
+
choices=choices,
|
|
60
|
+
default=None,
|
|
61
|
+
help="Disable this type checker when using revealtype-injector plugin",
|
|
62
|
+
)
|
|
63
|
+
for adp in adapters:
|
|
64
|
+
adp.add_pytest_option(group)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
68
|
+
# Forget config stash, it can't store collection of unserialized objects
|
|
69
|
+
for adp in adapter.discovery():
|
|
70
|
+
if config.option.revealtype_disable_adapter == adp.id:
|
|
71
|
+
adp.enabled = False
|
|
72
|
+
_logger.info(f"Disable {adp.id} adapter based on command line option")
|
|
73
|
+
else:
|
|
74
|
+
adp.set_config_file(config)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
from typing import (
|
|
7
|
+
Any,
|
|
8
|
+
ForwardRef,
|
|
9
|
+
TypeVar,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from typeguard import (
|
|
13
|
+
TypeCheckError,
|
|
14
|
+
TypeCheckMemo,
|
|
15
|
+
check_type_internal,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from . import adapter
|
|
19
|
+
from .models import (
|
|
20
|
+
FilePos,
|
|
21
|
+
TypeCheckerError,
|
|
22
|
+
VarType,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_T = TypeVar("_T")
|
|
26
|
+
|
|
27
|
+
_logger = logging.getLogger(__name__)
|
|
28
|
+
_logger.setLevel(logging.WARN)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RevealTypeExtractor(ast.NodeVisitor):
|
|
32
|
+
target = None
|
|
33
|
+
|
|
34
|
+
def visit_Call(self, node: ast.Call) -> Any:
|
|
35
|
+
# HACK node.func is not necessarily "reveal_type" as we allow
|
|
36
|
+
# "import as" syntax. We just assume the outmost call is
|
|
37
|
+
# reveal_type(), and never descend into recursive ast.Call nodes.
|
|
38
|
+
# IDEA Is it possible to retrieve the function name from
|
|
39
|
+
# pytest_pyfunc_call() hook and store it in stash somewhere?
|
|
40
|
+
self.target = node.args[0]
|
|
41
|
+
return node
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_var_name(frame: inspect.Traceback) -> str | None:
|
|
45
|
+
ctxt, idx = frame.code_context, frame.index
|
|
46
|
+
assert ctxt is not None and idx is not None
|
|
47
|
+
code = ctxt[idx].strip()
|
|
48
|
+
|
|
49
|
+
walker = RevealTypeExtractor()
|
|
50
|
+
# TODO Use 'exec' mode which results in more complex AST but doesn't impose
|
|
51
|
+
# as much restriction on test code as 'eval' mode does.
|
|
52
|
+
walker.visit(ast.parse(code, mode="eval"))
|
|
53
|
+
assert walker.target is not None
|
|
54
|
+
return ast.get_source_segment(code, walker.target)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def revealtype_injector(var: _T) -> _T:
|
|
58
|
+
"""Replacement of `reveal_type()` that matches static and runtime type
|
|
59
|
+
checking result
|
|
60
|
+
|
|
61
|
+
This function is intended as a drop-in replacement of `reveal_type()` from
|
|
62
|
+
Python 3.11 or `typing_extensions` module. Under the hook, it uses
|
|
63
|
+
`typeguard` to get runtime variable type, and compare it with static type
|
|
64
|
+
checker results for coherence.
|
|
65
|
+
|
|
66
|
+
Usage
|
|
67
|
+
-----
|
|
68
|
+
No special handling is required. Just import `reveal_type` as usual in
|
|
69
|
+
pytest test functions, and it will be replaced with this function behind the
|
|
70
|
+
scene. However, since `reveal_type()` is not available in Python 3.10 or
|
|
71
|
+
earlier, you need to import it conditionally, like this:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
if sys.version_info >= (3, 11):
|
|
75
|
+
from typing import reveal_type
|
|
76
|
+
else:
|
|
77
|
+
from typing_extensions import reveal_type
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The signature is identical to official `reveal_type()`:
|
|
81
|
+
returns input argument unchanged.
|
|
82
|
+
|
|
83
|
+
Raises
|
|
84
|
+
------
|
|
85
|
+
`TypeCheckerError`
|
|
86
|
+
If static type checker failed to get inferred type
|
|
87
|
+
for variable
|
|
88
|
+
`typeguard.TypeCheckError`
|
|
89
|
+
If type checker result doesn't match runtime result
|
|
90
|
+
"""
|
|
91
|
+
# As a wrapper of typeguard.check_type_interal(),
|
|
92
|
+
# get data from my caller, not mine
|
|
93
|
+
caller_frame = sys._getframe(1) # pyright: ignore[reportPrivateUsage]
|
|
94
|
+
caller = inspect.getframeinfo(caller_frame)
|
|
95
|
+
var_name = _get_var_name(caller)
|
|
96
|
+
pos = FilePos(pathlib.Path(caller.filename).name, caller.lineno)
|
|
97
|
+
|
|
98
|
+
globalns = caller_frame.f_globals
|
|
99
|
+
localns = caller_frame.f_locals
|
|
100
|
+
|
|
101
|
+
for adp in adapter.discovery():
|
|
102
|
+
if not adp.enabled:
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
tc_result = adp.typechecker_result[pos]
|
|
106
|
+
except KeyError as e:
|
|
107
|
+
raise TypeCheckerError(
|
|
108
|
+
f"No inferred type from {adp.id}", pos.file, pos.lineno
|
|
109
|
+
) from e
|
|
110
|
+
|
|
111
|
+
if tc_result.var: # Only pyright has this extra protection
|
|
112
|
+
if tc_result.var != var_name:
|
|
113
|
+
raise TypeCheckerError(
|
|
114
|
+
f'Variable name should be "{tc_result.var}", but got "{var_name}"',
|
|
115
|
+
pos.file,
|
|
116
|
+
pos.lineno,
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
adp.typechecker_result[pos] = VarType(var_name, tc_result.type)
|
|
120
|
+
|
|
121
|
+
ref = tc_result.type
|
|
122
|
+
try:
|
|
123
|
+
_ = eval(ref.__forward_arg__, globalns, localns)
|
|
124
|
+
except (TypeError, NameError):
|
|
125
|
+
ref_ast = ast.parse(ref.__forward_arg__, mode="eval")
|
|
126
|
+
walker = adp.create_collector(globalns, localns)
|
|
127
|
+
new_ast = walker.visit(ref_ast)
|
|
128
|
+
if walker.modified:
|
|
129
|
+
ref = ForwardRef(ast.unparse(new_ast))
|
|
130
|
+
memo = TypeCheckMemo(globalns, localns | walker.collected)
|
|
131
|
+
else:
|
|
132
|
+
memo = TypeCheckMemo(globalns, localns)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
check_type_internal(var, ref, memo)
|
|
136
|
+
except TypeCheckError as e:
|
|
137
|
+
e.args = (f"({adp.id}) " + e.args[0],) + e.args[1:]
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
return var
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import ast
|
|
5
|
+
import importlib
|
|
6
|
+
import pathlib
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from typing import (
|
|
10
|
+
Any,
|
|
11
|
+
ClassVar,
|
|
12
|
+
ForwardRef,
|
|
13
|
+
NamedTuple,
|
|
14
|
+
cast,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FilePos(NamedTuple):
|
|
21
|
+
file: str
|
|
22
|
+
lineno: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VarType(NamedTuple):
|
|
26
|
+
var: str | None
|
|
27
|
+
type: ForwardRef
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TypeCheckerError(Exception):
|
|
31
|
+
# Can be None when type checker dies before any code evaluation
|
|
32
|
+
def __init__(self, message: str, filename: str | None, lineno: int | None) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self._filename = filename
|
|
35
|
+
self._lineno = lineno
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
if self._filename:
|
|
39
|
+
return '"{}"{}: {}'.format(
|
|
40
|
+
self._filename,
|
|
41
|
+
" line " + str(self._lineno) if self._lineno else "",
|
|
42
|
+
self.args[0],
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
return str(self.args[0])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NameCollectorBase(ast.NodeTransformer):
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
globalns: dict[str, Any],
|
|
52
|
+
localns: dict[str, Any],
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__()
|
|
55
|
+
self._globalns = globalns
|
|
56
|
+
self._localns = localns
|
|
57
|
+
self.modified: bool = False
|
|
58
|
+
# typing_extensions guaranteed to be present,
|
|
59
|
+
# as a dependency of typeguard
|
|
60
|
+
self.collected: dict[str, Any] = {
|
|
61
|
+
m: importlib.import_module(m)
|
|
62
|
+
for m in ("builtins", "typing", "typing_extensions")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def visit_Subscript(self, node: ast.Subscript) -> ast.expr:
|
|
66
|
+
node.value = cast("ast.expr", self.visit(node.value))
|
|
67
|
+
node.slice = cast("ast.expr", self.visit(node.slice))
|
|
68
|
+
|
|
69
|
+
# When type reference is a stub-only specialized class
|
|
70
|
+
# which don't have runtime support (e.g. lxml classes have
|
|
71
|
+
# no __class_getitem__), concede by verifying
|
|
72
|
+
# non-subscripted type.
|
|
73
|
+
try:
|
|
74
|
+
eval(ast.unparse(node), self._globalns, self._localns | self.collected)
|
|
75
|
+
except TypeError as e:
|
|
76
|
+
if "is not subscriptable" not in e.args[0]:
|
|
77
|
+
raise
|
|
78
|
+
# TODO Insert node.value dependent hook for extra
|
|
79
|
+
# verification of subscript type
|
|
80
|
+
self.modified = True
|
|
81
|
+
return node.value
|
|
82
|
+
else:
|
|
83
|
+
return node
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TypeCheckerAdapter:
|
|
87
|
+
enabled: bool = True
|
|
88
|
+
config_file: ClassVar[pathlib.Path | None] = None
|
|
89
|
+
# Subclasses need to specify default values for below
|
|
90
|
+
id: ClassVar[str]
|
|
91
|
+
# {('file.py', 10): ('var_name', 'list[str]'), ...}
|
|
92
|
+
typechecker_result: ClassVar[dict[FilePos, VarType]]
|
|
93
|
+
_type_mesg_re: ClassVar[re.Pattern[str]]
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
@abc.abstractmethod
|
|
97
|
+
def run_typechecker_on(cls, paths: Iterable[pathlib.Path]) -> None: ...
|
|
98
|
+
@classmethod
|
|
99
|
+
@abc.abstractmethod
|
|
100
|
+
def create_collector(
|
|
101
|
+
cls, globalns: dict[str, Any], localns: dict[str, Any]
|
|
102
|
+
) -> NameCollectorBase: ...
|
|
103
|
+
@classmethod
|
|
104
|
+
@abc.abstractmethod
|
|
105
|
+
def set_config_file(cls, config: pytest.Config) -> None: ...
|
|
106
|
+
@staticmethod
|
|
107
|
+
@abc.abstractmethod
|
|
108
|
+
def add_pytest_option(group: pytest.OptionGroup) -> None: ...
|
|
File without changes
|