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.
@@ -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,3 @@
1
+ """Pytest plugin for replacing reveal_type() calls inside test functions with static and runtime type checking result comparison, for confirming type annotation validity.""" # noqa: E501
2
+
3
+ __version__ = "0.1.0"
@@ -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: ...
@@ -0,0 +1,6 @@
1
+ from .hooks import (
2
+ pytest_addoption as pytest_addoption,
3
+ pytest_collection_finish as pytest_collection_finish,
4
+ pytest_configure as pytest_configure,
5
+ pytest_pyfunc_call as pytest_pyfunc_call,
6
+ )