type-assert 0.1.0__py3-none-any.whl

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,27 @@
1
+ """Check a value's static type and its runtime type in one assertion.
2
+
3
+ A case file is a stack of one-line `assert_types(expression, ExpectedType)` calls.
4
+ A type checker checks the left of each pair against the right exactly; running the
5
+ line checks the value the expression actually produces. The pytest plugin turns
6
+ each line into two tests, so a disagreement between the two fails.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ._assertions import assert_types as assert_types
12
+ from ._cases import Case as Case
13
+ from ._cases import CaseError as CaseError
14
+ from ._cases import CaseFile as CaseFile
15
+ from ._cases import CaseSkipped as CaseSkipped
16
+ from ._cases import collect_case_file as collect_case_file
17
+ from ._cases import collect_cases as collect_cases
18
+ from ._checkers import CHECKERS as CHECKERS
19
+ from ._checkers import Checker as Checker
20
+ from ._checkers import CheckerError as CheckerError
21
+ from ._checkers import Diagnostic as Diagnostic
22
+ from ._checkers import get_checker as get_checker
23
+
24
+ try:
25
+ from ._version import __version__ as __version__
26
+ except ImportError: # pragma: no cover - only when running from a source tree
27
+ __version__ = '0.0.0.dev0'
@@ -0,0 +1,55 @@
1
+ """The one assertion a typing case makes.
2
+
3
+ To a type checker `assert_types` is `typing_extensions.assert_type`; at runtime it
4
+ is a real checker. See the module body for why that works.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ from typing import TYPE_CHECKING
11
+ from typing import Any
12
+
13
+ if TYPE_CHECKING:
14
+ # A checker resolves an aliased import back to its original definition, so it
15
+ # applies its `assert_type` special case here: the inferred type must match
16
+ # `expected` *exactly*, not merely be assignable to it. Verified against both
17
+ # mypy and pyright. At runtime the definition below runs instead and checks the
18
+ # value, so one call covers both halves and they cannot drift apart.
19
+ from typing_extensions import assert_type as assert_types
20
+ else:
21
+ from pycroscope.checker import Checker
22
+ from pycroscope.runtime import CanAssignError
23
+ from pycroscope.runtime import KnownValue
24
+ from pycroscope.runtime import Relation
25
+ from pycroscope.runtime import has_relation
26
+ from pycroscope.runtime import type_from_runtime
27
+
28
+ @functools.cache
29
+ def _checker() -> Checker:
30
+ """Return the shared checker, built on first use."""
31
+ return Checker()
32
+
33
+ def assert_types(value: object, expected: Any) -> object:
34
+ """Assert `value` is assignable to `expected` at runtime, and return it."""
35
+ # pycroscope's own `get_assignability_error` memoises against a module-global
36
+ # checker, which keeps every checked value alive for the rest of the session.
37
+ # Use our own so the memo can be dropped after each check.
38
+ checker = _checker()
39
+ try:
40
+ relation = has_relation(
41
+ type_from_runtime(expected), KnownValue(value), Relation.ASSIGNABLE, checker
42
+ )
43
+ finally:
44
+ cache = checker.get_relation_cache()
45
+ if cache is not None:
46
+ cache.clear()
47
+
48
+ if isinstance(relation, CanAssignError):
49
+ msg = (
50
+ f'Runtime value of type {type(value).__name__!r} is not assignable '
51
+ f'to the expected type:\n\t{expected}\n\n{relation.display(depth=0)}'
52
+ )
53
+ # An assertion that failed, not a caller passing the wrong kind of argument.
54
+ raise AssertionError(msg) # noqa: TRY004
55
+ return value
type_assert/_cases.py ADDED
@@ -0,0 +1,182 @@
1
+ """Split a case file into the setup it needs and the cases it declares."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+ from typing import Any
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+ from types import CodeType
13
+
14
+ ASSERTION = 'assert_types'
15
+ SKIP_RUNTIME = 'SKIP_RUNTIME'
16
+
17
+
18
+ class CaseError(Exception):
19
+ """Raised when a case file is not shaped the way the framework expects."""
20
+
21
+
22
+ class CaseSkipped(Exception): # noqa: N818
23
+ """Raised instead of running a case the file asks to skip at runtime."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Case:
28
+ """One `assert_types(expression, ExpectedType)` line."""
29
+
30
+ path: Path
31
+ lines: frozenset[int]
32
+ expression: str
33
+ expected: str
34
+ code: CodeType
35
+
36
+ @property
37
+ def id(self) -> str:
38
+ """Return the test id, which reads as the claim the case makes."""
39
+ return f'{self.expression} -> {self.expected}'
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class CaseFile:
44
+ """One case file: the cases it declares, and the setup they share."""
45
+
46
+ path: Path
47
+ cases: tuple[Case, ...]
48
+ setup_lines: frozenset[int]
49
+ setup_code: CodeType | None = None
50
+ error: str | None = None
51
+
52
+ @property
53
+ def name(self) -> str:
54
+ """Return the file name, used to scope test ids."""
55
+ return self.path.name
56
+
57
+ def setup_namespace(self) -> dict[str, Any]:
58
+ """Execute this file's setup and return the namespace it produced."""
59
+ namespace: dict[str, Any] = {'__name__': self.path.stem, '__file__': str(self.path)}
60
+ # Running the case file's own code is the point of the framework.
61
+ exec(self.setup_code, namespace) # noqa: S102
62
+ return namespace
63
+
64
+ def run(self, case: Case) -> None:
65
+ """Execute one case, and only it, against a fresh copy of the setup.
66
+
67
+ Rebuilding the setup for every case is what keeps cases independent: one
68
+ cannot observe another's state, and reordering the file changes nothing.
69
+ """
70
+ namespace = self.setup_namespace()
71
+ reason = skip_reason(namespace, case)
72
+ if reason is not None:
73
+ raise CaseSkipped(reason)
74
+ exec(case.code, namespace) # noqa: S102
75
+
76
+ def unknown_skips(self, namespace: dict[str, Any]) -> list[str]:
77
+ """Return `SKIP_RUNTIME` keys that match no case, so a stale one is caught."""
78
+ declared = namespace.get(SKIP_RUNTIME) or {}
79
+ expressions = {case.expression for case in self.cases}
80
+ return sorted(key for key in declared if key not in expressions)
81
+
82
+
83
+ def skip_reason(namespace: dict[str, Any], case: Case) -> str | None:
84
+ """Return why `case` should not run, from the file's `SKIP_RUNTIME` mapping.
85
+
86
+ A case file maps an expression to the reason running it would fail — a
87
+ platform crash, an unavailable dependency. Mypy still checks the case, so
88
+ only the runtime half is skipped. Building the mapping conditionally is
89
+ ordinary Python, since it is read after the file's setup has run.
90
+ """
91
+ declared = namespace.get(SKIP_RUNTIME) or {}
92
+ return declared.get(case.expression) or None
93
+
94
+
95
+ def _case_call(node: ast.AST) -> ast.Call | None:
96
+ """Return the `assert_types` call a top-level statement makes, if it makes one."""
97
+ if not isinstance(node, ast.Expr):
98
+ return None
99
+ call = node.value
100
+ if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Name):
101
+ return None
102
+ return call if call.func.id == ASSERTION else None
103
+
104
+
105
+ def _reject_nested_assertions(tree: ast.Module, path: Path) -> None:
106
+ """Reject `assert_types` calls that are not statements at module level.
107
+
108
+ Such a call still type-checks, but it never becomes a case of its own, so it
109
+ would be silently left out of the runtime half.
110
+ """
111
+ top_level = {id(call) for call in map(_case_call, tree.body) if call is not None}
112
+ nested = [
113
+ node
114
+ for node in ast.walk(tree)
115
+ if isinstance(node, ast.Call)
116
+ and isinstance(node.func, ast.Name)
117
+ and node.func.id == ASSERTION
118
+ and id(node) not in top_level
119
+ ]
120
+ if nested:
121
+ lines = ', '.join(str(node.lineno) for node in nested)
122
+ msg = (
123
+ f'{path.name}: `{ASSERTION}` must be a statement at module level so that it '
124
+ f'becomes a case of its own. Found one nested at line(s) {lines}.'
125
+ )
126
+ raise CaseError(msg)
127
+
128
+
129
+ def collect_case_file(path: Path) -> CaseFile:
130
+ """Parse one case file into its cases and the setup they share.
131
+
132
+ A file this cannot make sense of yields a `CaseFile` carrying the reason
133
+ rather than raising, so a malformed file fails its own test instead of
134
+ aborting collection for the session.
135
+ """
136
+ try:
137
+ return _parse_case_file(path)
138
+ except (CaseError, SyntaxError, OSError) as error:
139
+ return CaseFile(path=path, cases=(), setup_lines=frozenset(), error=str(error))
140
+
141
+
142
+ def _parse_case_file(path: Path) -> CaseFile:
143
+ """Parse one case file, raising `CaseError` if it is not shaped as expected."""
144
+ source = path.read_text(encoding='utf-8')
145
+ tree = ast.parse(source, filename=str(path))
146
+ _reject_nested_assertions(tree, path)
147
+
148
+ setup = [node for node in tree.body if _case_call(node) is None]
149
+ setup_module = ast.Module(body=setup, type_ignores=[])
150
+ setup_code = compile(ast.fix_missing_locations(setup_module), str(path), 'exec')
151
+
152
+ cases = []
153
+ for node in tree.body:
154
+ call = _case_call(node)
155
+ if call is None:
156
+ continue
157
+ if len(call.args) != 2:
158
+ msg = f'{path.name}:{node.lineno}: `{ASSERTION}` takes an expression and a type.'
159
+ raise CaseError(msg)
160
+ module = ast.Module(body=[node], type_ignores=[])
161
+ cases.append(
162
+ Case(
163
+ path=path,
164
+ lines=frozenset(range(node.lineno, (node.end_lineno or node.lineno) + 1)),
165
+ expression=ast.unparse(call.args[0]),
166
+ expected=ast.unparse(call.args[1]),
167
+ code=compile(ast.fix_missing_locations(module), str(path), 'exec'),
168
+ )
169
+ )
170
+
171
+ case_lines = frozenset().union(*(case.lines for case in cases)) if cases else frozenset()
172
+ return CaseFile(
173
+ path=path,
174
+ cases=tuple(cases),
175
+ setup_lines=frozenset(range(1, len(source.splitlines()) + 1)) - case_lines,
176
+ setup_code=setup_code,
177
+ )
178
+
179
+
180
+ def collect_cases(directory: Path) -> list[CaseFile]:
181
+ """Parse every case file in `directory`."""
182
+ return [collect_case_file(path) for path in sorted(directory.glob('*.py'))]
@@ -0,0 +1,43 @@
1
+ """Type checker backends, and the registry that selects one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._base import Checker
6
+ from ._base import CheckerError
7
+ from ._base import Diagnostic
8
+ from ._mypy import MypyChecker
9
+ from ._pyrefly import PyreflyChecker
10
+ from ._pyright import PyrightChecker
11
+
12
+ __all__ = [
13
+ 'CHECKERS',
14
+ 'Checker',
15
+ 'CheckerError',
16
+ 'Diagnostic',
17
+ 'MypyChecker',
18
+ 'PyreflyChecker',
19
+ 'PyrightChecker',
20
+ 'get_checker',
21
+ ]
22
+
23
+ #: Every checker that can be selected, by name.
24
+ #:
25
+ #: `ty` is deliberately absent: it is pre-1.0 and its output format is still moving,
26
+ #: so supporting it would mean tracking those changes. Adding a backend is one module
27
+ #: implementing `Checker` plus an entry here.
28
+ CHECKERS: dict[str, type[Checker]] = {
29
+ MypyChecker.name: MypyChecker,
30
+ PyreflyChecker.name: PyreflyChecker,
31
+ PyrightChecker.name: PyrightChecker,
32
+ }
33
+
34
+
35
+ def get_checker(name: str) -> Checker:
36
+ """Return the checker called `name`."""
37
+ try:
38
+ checker = CHECKERS[name]
39
+ except KeyError:
40
+ known = ', '.join(sorted(CHECKERS))
41
+ msg = f'Unknown type checker {name!r}. Available: {known}.'
42
+ raise CheckerError(msg) from None
43
+ return checker()
@@ -0,0 +1,54 @@
1
+ """What every type checker backend has to provide."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+
12
+
13
+ class CheckerError(Exception):
14
+ """Raised when a checker could not run, as opposed to reporting diagnostics."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Diagnostic:
19
+ """One error a checker reported, at one line of one file."""
20
+
21
+ path: Path
22
+ line: int
23
+ message: str
24
+
25
+
26
+ class Checker:
27
+ """Runs a type checker over a package and returns its errors keyed by file.
28
+
29
+ Subclasses supply the command to run and how to read its output. Both run the
30
+ checker in a separate process, so a crash surfaces as a failed call rather
31
+ than taking the test session down with it.
32
+ """
33
+
34
+ #: Name this checker is selected by.
35
+ name: str
36
+ #: Distribution to install to get it, when it is missing.
37
+ distribution: str
38
+
39
+ def run(
40
+ self,
41
+ package: str,
42
+ *,
43
+ root: Path,
44
+ cache_dir: Path | None,
45
+ extra_args: Sequence[str] = (),
46
+ ) -> dict[Path, list[Diagnostic]]:
47
+ """Type-check `package` from `root` and return its errors keyed by file.
48
+
49
+ Running from `root` is what makes the project's own checker configuration
50
+ apply, since that is where every checker looks for it. `extra_args` covers
51
+ what the configuration cannot say: a different config file, a Python
52
+ version, a strictness flag.
53
+ """
54
+ raise NotImplementedError
@@ -0,0 +1,86 @@
1
+ """The mypy backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Sequence
13
+
14
+ from ._base import Checker
15
+ from ._base import CheckerError
16
+ from ._base import Diagnostic
17
+
18
+ # `path:line:col: severity: message`, with the column absent on whole-file diagnostics.
19
+ _DIAGNOSTIC = re.compile(r'^(?P<path>.+?):(?P<line>\d+):(?:\d+:)? (?P<severity>\w+): (?P<msg>.*)$')
20
+
21
+
22
+ class MypyChecker(Checker):
23
+ """Runs mypy and reads its text output."""
24
+
25
+ name = 'mypy'
26
+ distribution = 'mypy'
27
+
28
+ def run(
29
+ self,
30
+ package: str,
31
+ *,
32
+ root: Path,
33
+ cache_dir: Path | None,
34
+ extra_args: Sequence[str] = (),
35
+ ) -> dict[Path, list[Diagnostic]]:
36
+ """Type-check `package` from `root` and return mypy's errors keyed by file.
37
+
38
+ mypy discovers the project's own configuration from `root`, so nothing has
39
+ to be restated here. Anything it cannot express goes in `extra_args`.
40
+ """
41
+ # `--follow-imports=silent` types the symbols the cases use without reporting
42
+ # the host project's own diagnostics, which vary by platform and dependency
43
+ # versions and have nothing to do with the cases. It comes before `extra_args`
44
+ # so a project that wants a different setting can say so.
45
+ defaults = ['--follow-imports=silent']
46
+ if cache_dir is not None:
47
+ defaults.append(f'--cache-dir={cache_dir}')
48
+ # These come after, because the output is parsed and has to stay parsable.
49
+ required = ['--no-color-output', '--no-error-summary', '--no-pretty', '--show-traceback']
50
+ args = [
51
+ sys.executable,
52
+ '-m',
53
+ 'mypy',
54
+ *defaults,
55
+ *extra_args,
56
+ *required,
57
+ '--package',
58
+ package,
59
+ ]
60
+
61
+ try:
62
+ process = subprocess.run(args, capture_output=True, cwd=root, text=True, check=False)
63
+ except OSError as error: # pragma: no cover - defensive
64
+ msg = f'Could not run mypy: {error}'
65
+ raise CheckerError(msg) from error
66
+
67
+ # mypy exits 1 when it reports diagnostics and 2 when it could not run.
68
+ if process.returncode > 1 or process.stderr:
69
+ hint = ''
70
+ if 'No module named mypy' in process.stderr:
71
+ hint = '\n\nInstall it with: pip install type-assert[mypy]'
72
+ msg = (
73
+ f'mypy failed to run:\n{" ".join(args)}\n\n{process.stderr}{process.stdout}{hint}'
74
+ )
75
+ raise CheckerError(msg)
76
+
77
+ diagnostics: dict[Path, list[Diagnostic]] = {}
78
+ for line in process.stdout.splitlines():
79
+ match = _DIAGNOSTIC.match(line)
80
+ if match is None or match['severity'] != 'error':
81
+ continue
82
+ path = (Path(root) / match['path']).resolve()
83
+ diagnostics.setdefault(path, []).append(
84
+ Diagnostic(path=path, line=int(match['line']), message=match['msg'])
85
+ )
86
+ return diagnostics
@@ -0,0 +1,100 @@
1
+ """The pyrefly backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from typing import TYPE_CHECKING
10
+
11
+ from ._base import Checker
12
+ from ._base import CheckerError
13
+ from ._base import Diagnostic
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Sequence
17
+
18
+ # `ERROR path:line:col-col: message`, the shape of `--output-format min-text`.
19
+ _DIAGNOSTIC = re.compile(r'^ERROR (?P<path>.+?):(?P<line>\d+):\d+(?:-\d+)?: (?P<msg>.*)$')
20
+
21
+ # pyrefly says this, then checks nothing at all and reports success.
22
+ _NO_CONFIG = 'No `pyrefly.toml` found'
23
+
24
+
25
+ class PyreflyChecker(Checker):
26
+ """Runs pyrefly and reads its one-line-per-error output."""
27
+
28
+ name = 'pyrefly'
29
+ distribution = 'pyrefly'
30
+
31
+ def run(
32
+ self,
33
+ package: str,
34
+ *,
35
+ root: Path,
36
+ cache_dir: Path | None,
37
+ extra_args: Sequence[str] = (),
38
+ ) -> dict[Path, list[Diagnostic]]:
39
+ """Type-check `package` from `root` and return pyrefly's errors keyed by file.
40
+
41
+ pyrefly discovers the project's own configuration from `root`, and unlike
42
+ the others it will not check anything without one -- see below.
43
+ """
44
+ del cache_dir # pyrefly keeps no cache of its own to point elsewhere.
45
+ target = Path(root) / Path(*package.split('.'))
46
+ # pyrefly reports success for a path that does not exist, where mypy and
47
+ # pyright both refuse. Refuse here too: passing for nothing is the one
48
+ # outcome a checker must never produce.
49
+ if not target.exists():
50
+ msg = f'pyrefly was asked to check {target}, which does not exist.'
51
+ raise CheckerError(msg)
52
+ args = [
53
+ sys.executable,
54
+ '-m',
55
+ 'pyrefly',
56
+ 'check',
57
+ '--python-interpreter-path',
58
+ sys.executable,
59
+ *extra_args,
60
+ # Last, because the output is parsed and has to stay parsable.
61
+ '--output-format',
62
+ 'min-text',
63
+ str(target),
64
+ ]
65
+
66
+ try:
67
+ process = subprocess.run(args, capture_output=True, cwd=root, text=True, check=False)
68
+ except OSError as error: # pragma: no cover - defensive
69
+ msg = f'Could not run pyrefly: {error}'
70
+ raise CheckerError(msg) from error
71
+
72
+ output = process.stdout + process.stderr
73
+ if 'No module named pyrefly' in process.stderr:
74
+ msg = (
75
+ f'pyrefly failed to run:\n{" ".join(args)}\n\n{output}\n\n'
76
+ f'Install it with: pip install type-assert[pyrefly]'
77
+ )
78
+ raise CheckerError(msg)
79
+
80
+ # Without a config pyrefly ignores the paths it is given and reports success,
81
+ # which would silently pass every case. Refuse rather than pass for nothing.
82
+ if _NO_CONFIG in output:
83
+ msg = (
84
+ 'pyrefly found no configuration and so checked nothing, which would '
85
+ 'pass every case without looking at it. Give the project a '
86
+ '`pyrefly.toml`, or a `[tool.pyrefly]` table in `pyproject.toml`, '
87
+ f'naming the cases directory in `project-includes`.\n\n{output}'
88
+ )
89
+ raise CheckerError(msg)
90
+
91
+ diagnostics: dict[Path, list[Diagnostic]] = {}
92
+ for line in output.splitlines():
93
+ match = _DIAGNOSTIC.match(line.strip())
94
+ if match is None:
95
+ continue
96
+ path = (Path(root) / match['path']).resolve()
97
+ diagnostics.setdefault(path, []).append(
98
+ Diagnostic(path=path, line=int(match['line']), message=match['msg'])
99
+ )
100
+ return diagnostics
@@ -0,0 +1,97 @@
1
+ """The pyright backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ import subprocess
8
+ import sys
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Sequence
13
+
14
+ from ._base import Checker
15
+ from ._base import CheckerError
16
+ from ._base import Diagnostic
17
+
18
+
19
+ def _extract_report(stdout: str) -> dict | None:
20
+ """Return the JSON report from pyright's output, or `None` if there is none.
21
+
22
+ The `pyright` distribution downloads its own copy of node on first use and
23
+ announces it on stdout, so the report is not always the whole of it.
24
+ """
25
+ for start, line in enumerate(stdout.splitlines(keepends=True)):
26
+ if line.startswith('{'):
27
+ offset = sum(len(previous) for previous in stdout.splitlines(keepends=True)[:start])
28
+ try:
29
+ return json.loads(stdout[offset:])
30
+ except json.JSONDecodeError:
31
+ continue
32
+ return None
33
+
34
+
35
+ class PyrightChecker(Checker):
36
+ """Runs pyright and reads its JSON output."""
37
+
38
+ name = 'pyright'
39
+ distribution = 'pyright'
40
+
41
+ def run(
42
+ self,
43
+ package: str,
44
+ *,
45
+ root: Path,
46
+ cache_dir: Path | None,
47
+ extra_args: Sequence[str] = (),
48
+ ) -> dict[Path, list[Diagnostic]]:
49
+ """Type-check `package` from `root` and return pyright's errors keyed by file.
50
+
51
+ pyright discovers the project's own configuration from `root`, so nothing
52
+ has to be restated here. Anything it cannot express goes in `extra_args`.
53
+ """
54
+ del cache_dir # pyright keeps no cache of its own to point elsewhere.
55
+ # pyright reports only on the files it is given, so unlike mypy it needs no
56
+ # equivalent of `--follow-imports=silent` to stay quiet about the host project.
57
+ target = Path(root) / Path(*package.split('.'))
58
+ args = [
59
+ sys.executable,
60
+ '-m',
61
+ 'pyright',
62
+ '--pythonpath',
63
+ sys.executable,
64
+ *extra_args,
65
+ # Last, because the output is parsed and has to stay parsable.
66
+ '--outputjson',
67
+ str(target),
68
+ ]
69
+
70
+ try:
71
+ process = subprocess.run(args, capture_output=True, cwd=root, text=True, check=False)
72
+ except OSError as error: # pragma: no cover - defensive
73
+ msg = f'Could not run pyright: {error}'
74
+ raise CheckerError(msg) from error
75
+
76
+ report = _extract_report(process.stdout)
77
+ if report is None:
78
+ hint = ''
79
+ if 'No module named pyright' in process.stderr:
80
+ hint = '\n\nInstall it with: pip install type-assert[pyright]'
81
+ msg = (
82
+ f'pyright failed to run:\n{" ".join(args)}\n\n'
83
+ f'{process.stderr}{process.stdout}{hint}'
84
+ )
85
+ raise CheckerError(msg)
86
+
87
+ diagnostics: dict[Path, list[Diagnostic]] = {}
88
+ for entry in report.get('generalDiagnostics', []):
89
+ if entry.get('severity') != 'error':
90
+ continue
91
+ path = Path(entry['file']).resolve()
92
+ # pyright counts lines from zero; everything else here counts from one.
93
+ line = entry['range']['start']['line'] + 1
94
+ diagnostics.setdefault(path, []).append(
95
+ Diagnostic(path=path, line=line, message=entry['message'].replace('\n', ' '))
96
+ )
97
+ return diagnostics
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = 'g868a2293d'
type_assert/plugin.py ADDED
@@ -0,0 +1,227 @@
1
+ """Pytest integration: collect case files and run each case as its own test.
2
+
3
+ Registered as a pytest plugin by entry point, so a project only has to say where
4
+ its cases live::
5
+
6
+ [tool.pytest.ini_options]
7
+ type_assert_cases = 'tests/typing/cases'
8
+ type_assert_checkers = 'mypy pyright'
9
+
10
+ Each case file then collects as a test file of its own: one `setup` test for the
11
+ lines that are not cases, one runtime test per case, and one static test per case
12
+ per configured checker.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING
19
+
20
+ import pytest
21
+
22
+ from ._cases import CaseSkipped
23
+ from ._cases import collect_case_file
24
+ from ._checkers import CHECKERS
25
+ from ._checkers import get_checker
26
+
27
+ if TYPE_CHECKING:
28
+ from ._cases import Case
29
+ from ._cases import CaseFile
30
+ from ._checkers import Diagnostic
31
+
32
+ CASES_INI = 'type_assert_cases'
33
+ CHECKERS_INI = 'type_assert_checkers'
34
+ DEFAULT_CHECKERS = ('mypy',)
35
+
36
+ _DIAGNOSTICS = '_type_assert_diagnostics'
37
+
38
+
39
+ def pytest_addoption(parser: pytest.Parser) -> None:
40
+ """Register the settings a project needs."""
41
+ parser.addini(
42
+ CASES_INI,
43
+ 'Directory of type_assert case files, relative to the rootdir.',
44
+ default='',
45
+ )
46
+ parser.addini(
47
+ CHECKERS_INI,
48
+ 'Type checkers to check the cases with, whitespace separated. Any of: '
49
+ 'mypy, pyright. Each one gets a static test of its own per case, so a '
50
+ "case can be held to more than one checker's inference. Defaults to "
51
+ f'{" ".join(DEFAULT_CHECKERS)}.',
52
+ type='args',
53
+ default=list(DEFAULT_CHECKERS),
54
+ )
55
+ # One per registered checker, since their flags have nothing in common. Each
56
+ # checker already reads the project's own configuration -- it runs from the
57
+ # rootdir, which is where every checker looks -- so this is for what that
58
+ # configuration cannot say: a different config file, a Python version, a
59
+ # strictness flag that should apply to the cases and nothing else.
60
+ for name in sorted(CHECKERS):
61
+ parser.addini(
62
+ checker_args_ini(name),
63
+ f'Extra command line arguments for {name}, whitespace separated.',
64
+ type='args',
65
+ default=[],
66
+ )
67
+
68
+
69
+ def checker_args_ini(checker_name: str) -> str:
70
+ """Return the ini option holding extra arguments for `checker_name`."""
71
+ return f'type_assert_{checker_name}_args'
72
+
73
+
74
+ def configured_checkers(config: pytest.Config) -> list[str]:
75
+ """Return the names of the checkers the cases are checked with."""
76
+ names = [str(name) for name in config.getini(CHECKERS_INI) if str(name).strip()]
77
+ return names or list(DEFAULT_CHECKERS)
78
+
79
+
80
+ def cases_dir(config: pytest.Config) -> Path | None:
81
+ """Return the configured cases directory, or `None` when unset."""
82
+ configured = str(config.getini(CASES_INI)).strip()
83
+ if not configured:
84
+ return None
85
+ return (Path(config.rootpath) / configured).resolve()
86
+
87
+
88
+ def pytest_collect_file(file_path: Path, parent: pytest.Collector):
89
+ """Collect a `.py` file in the configured cases directory as a case file."""
90
+ directory = cases_dir(parent.config)
91
+ if directory is None or file_path.suffix != '.py' or file_path.parent != directory:
92
+ return None
93
+ return CaseFileCollector.from_parent(parent, path=file_path)
94
+
95
+
96
+ def _diagnostics(config: pytest.Config, checker_name: str) -> dict[Path, list[Diagnostic]]:
97
+ """Run one checker once per session and cache its result on the config."""
98
+ cache = getattr(config, _DIAGNOSTICS, None)
99
+ if cache is None:
100
+ cache = {}
101
+ setattr(config, _DIAGNOSTICS, cache)
102
+ if checker_name not in cache:
103
+ directory = cases_dir(config)
104
+ assert directory is not None # only reachable from a collected case file
105
+ root = Path(config.rootpath)
106
+ checker = get_checker(checker_name)
107
+ # A cache directory per checker and per xdist worker: the run is cheap once
108
+ # warm, and sharing one between concurrent workers is what makes it stale.
109
+ worker = getattr(config, 'workerinput', {}).get('workerid', 'master')
110
+ cache[checker_name] = checker.run(
111
+ '.'.join(directory.relative_to(root).parts),
112
+ root=root,
113
+ cache_dir=root / '.mypy_cache' / f'type_assert-{checker_name}-{worker}',
114
+ extra_args=[str(arg) for arg in config.getini(checker_args_ini(checker_name))],
115
+ )
116
+ return cache[checker_name]
117
+
118
+
119
+ def _report(case_file: CaseFile, checker_name: str, errors: list[Diagnostic]) -> str:
120
+ """Return the checker's messages against the source lines they came from."""
121
+ source = case_file.path.read_text(encoding='utf-8').splitlines()
122
+ body = '\n'.join(
123
+ f'{case_file.name}:{error.line}: {error.message}\n\t{source[error.line - 1].strip()}'
124
+ for error in errors
125
+ )
126
+ return f'{checker_name} reported {len(errors)} error(s):\n{body}'
127
+
128
+
129
+ class CaseFileCollector(pytest.File):
130
+ """Collects one case file as a test file."""
131
+
132
+ def collect(self):
133
+ """Yield the file's setup test, and per case a runtime test and a static one."""
134
+ case_file = collect_case_file(Path(self.path))
135
+ checkers = configured_checkers(self.config)
136
+ yield SetupItem.from_parent(self, name='setup', case_file=case_file)
137
+ for case in case_file.cases:
138
+ yield RuntimeItem.from_parent(
139
+ self, name=f'{case.id} [runtime]', case_file=case_file, case=case
140
+ )
141
+ for checker_name in checkers:
142
+ yield StaticItem.from_parent(
143
+ self,
144
+ name=f'{case.id} [static: {checker_name}]',
145
+ case_file=case_file,
146
+ case=case,
147
+ checker_name=checker_name,
148
+ )
149
+
150
+
151
+ class _Item(pytest.Item):
152
+ """Shared plumbing for the tests a case file collects."""
153
+
154
+ def __init__(self, *args, case_file: CaseFile, **kwargs) -> None:
155
+ """Record the case file this test belongs to."""
156
+ super().__init__(*args, **kwargs)
157
+ self.case_file = case_file
158
+
159
+ def reportinfo(self):
160
+ """Locate this test in its case file."""
161
+ return self.path, None, self.name
162
+
163
+ def errors_on(self, checker_name: str, lines: frozenset[int]) -> list[Diagnostic]:
164
+ """Return one checker's errors falling on `lines` of this case file."""
165
+ reported = _diagnostics(self.config, checker_name).get(self.case_file.path.resolve(), [])
166
+ return [diagnostic for diagnostic in reported if diagnostic.line in lines]
167
+
168
+
169
+ class SetupItem(_Item):
170
+ """Checks a case file's setup: that it is well formed, runs, and type-checks."""
171
+
172
+ def runtest(self) -> None:
173
+ """Assert the file parses, its setup executes, and nothing else is wrong."""
174
+ # Report the file's own problem before asking for a type-check of it: a file
175
+ # the checker cannot parse fails the whole run, which would mask the reason.
176
+ if self.case_file.error is not None:
177
+ pytest.fail(self.case_file.error, pytrace=False)
178
+
179
+ namespace = self.case_file.setup_namespace()
180
+ unknown = self.case_file.unknown_skips(namespace)
181
+ if unknown:
182
+ listed = '\n'.join(f' {key}' for key in unknown)
183
+ pytest.fail(
184
+ f'SKIP_RUNTIME names expressions that no case in this file makes, so the '
185
+ f'skip no longer applies to anything:\n{listed}',
186
+ pytrace=False,
187
+ )
188
+
189
+ for checker_name in configured_checkers(self.config):
190
+ errors = self.errors_on(checker_name, self.case_file.setup_lines)
191
+ if errors:
192
+ pytest.fail(_report(self.case_file, checker_name, errors), pytrace=False)
193
+
194
+
195
+ class _CaseItem(_Item):
196
+ """A test about one case."""
197
+
198
+ def __init__(self, *args, case: Case, **kwargs) -> None:
199
+ """Record the case this test is about."""
200
+ super().__init__(*args, **kwargs)
201
+ self.case = case
202
+
203
+
204
+ class RuntimeItem(_CaseItem):
205
+ """Checks the value a case builds against the type the case expects."""
206
+
207
+ def runtest(self) -> None:
208
+ """Run this case, and only it."""
209
+ try:
210
+ self.case_file.run(self.case)
211
+ except CaseSkipped as skipped:
212
+ pytest.skip(str(skipped))
213
+
214
+
215
+ class StaticItem(_CaseItem):
216
+ """Checks the type one checker infers for a case against the type it expects."""
217
+
218
+ def __init__(self, *args, checker_name: str, **kwargs) -> None:
219
+ """Record which checker this test speaks for."""
220
+ super().__init__(*args, **kwargs)
221
+ self.checker_name = checker_name
222
+
223
+ def runtest(self) -> None:
224
+ """Assert the checker reports nothing on this case's lines."""
225
+ errors = self.errors_on(self.checker_name, self.case.lines)
226
+ if errors:
227
+ pytest.fail(_report(self.case_file, self.checker_name, errors), pytrace=False)
type_assert/py.typed ADDED
File without changes
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: type-assert
3
+ Version: 0.1.0
4
+ Summary: pytest plugin that checks a value static type and its runtime type in one assertion
5
+ Author: user27182
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/user27182/type-assert
8
+ Project-URL: Issues, https://github.com/user27182/type-assert/issues
9
+ Keywords: annotations,mypy,pyright,pytest,typing
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Pytest
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Software Development :: Quality Assurance
23
+ Classifier: Topic :: Software Development :: Testing
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: pycroscope<0.6,>=0.5
29
+ Requires-Dist: pytest>=7
30
+ Requires-Dist: typing-extensions>=4.5
31
+ Provides-Extra: all
32
+ Requires-Dist: type-assert[mypy,pyrefly,pyright]; extra == "all"
33
+ Provides-Extra: mypy
34
+ Requires-Dist: mypy>=1.11; extra == "mypy"
35
+ Provides-Extra: pyrefly
36
+ Requires-Dist: pyrefly>=1.0; extra == "pyrefly"
37
+ Provides-Extra: pyright
38
+ Requires-Dist: pyright>=1.1.390; extra == "pyright"
39
+ Dynamic: license-file
40
+
41
+ # type-assert
42
+
43
+ pytest plugin that checks a value's static type and its runtime type in one assertion.
44
+
45
+ A type checker only ever sees the annotations. A runtime checker only ever sees the
46
+ values. Either can be right while the other is wrong, and overloaded signatures are
47
+ where they drift apart. `type-assert` pins both halves at once, from one line:
48
+
49
+ ```python
50
+ assert_types(json.loads('[1]'), Any)
51
+ assert_types(sorted({'b', 'a'}), list[str])
52
+ ```
53
+
54
+ Each line becomes two tests. One runs the expression and checks the value it produced.
55
+ The other checks what a type checker inferred for the same line. The line only passes
56
+ if the two agree.
57
+
58
+ > **Warning** — The API of this package is unstable and likely to change between
59
+ > minor versions (for example `0.1.0` to `0.2.0`). Pin the exact version you
60
+ > depend on, for example `type-assert==0.1.0`.
61
+
62
+ ## Installation
63
+
64
+ ```bash
65
+ pip install type-assert[mypy] # or [pyright], [pyrefly], or [all]
66
+ ```
67
+
68
+ The checker itself is an extra, because it should be whichever one your project
69
+ already uses.
70
+
71
+ ## Usage
72
+
73
+ Put a directory of case files somewhere in your test tree and point the plugin at it:
74
+
75
+ ```toml
76
+ [tool.pytest.ini_options]
77
+ type_assert_cases = 'tests/typing/cases'
78
+ ```
79
+
80
+ A case file is an ordinary Python module. Every top-level `assert_types` call is a case;
81
+ everything else — imports, helpers, constants — is setup shared by the cases in that
82
+ file:
83
+
84
+ ```python
85
+ from __future__ import annotations
86
+
87
+ import json
88
+ from typing import Any
89
+
90
+ from type_assert import assert_types
91
+
92
+
93
+ def payload() -> str:
94
+ """Return a document to parse."""
95
+ return '{"a": 1}'
96
+
97
+
98
+ assert_types(json.loads(payload()), Any)
99
+ assert_types(sorted({'b', 'a'}), list[str])
100
+ assert_types(''.join([]), str)
101
+ ```
102
+
103
+ Running pytest collects each case file as a test file of its own:
104
+
105
+ ```text
106
+ tests/typing/cases/basics.py::setup
107
+ tests/typing/cases/basics.py::sorted({'b', 'a'}) -> list[str] [runtime]
108
+ tests/typing/cases/basics.py::sorted({'b', 'a'}) -> list[str] [static]
109
+ ```
110
+
111
+ ## How `assert_types` does both
112
+
113
+ To a type checker, `assert_types` *is*
114
+ [`typing_extensions.assert_type`](https://typing-extensions.readthedocs.io/en/latest/#typing_extensions.assert_type),
115
+ aliased under `TYPE_CHECKING`. Checkers resolve an aliased import back to its original
116
+ definition, so the special case still applies: the inferred type must match the second
117
+ argument **exactly**, and a supertype is a failure rather than a pass.
118
+
119
+ At runtime that name is bound to a real checker instead, backed by
120
+ [pycroscope](https://pycroscope.readthedocs.io/), which walks containers exhaustively —
121
+ it catches a `None` at any position in a `list[int]`, not only the first element.
122
+
123
+ Writing the type once covers both halves, and there is no way for them to drift apart.
124
+
125
+ ## Choosing a checker
126
+
127
+ ```toml
128
+ [tool.pytest.ini_options]
129
+ type_assert_checkers = 'mypy' # the default
130
+ type_assert_checkers = 'pyright'
131
+ type_assert_checkers = 'mypy pyright pyrefly' # each with its own test
132
+ ```
133
+
134
+ Naming more than one gives every case a static test per checker, so a case has to hold
135
+ under all of them:
136
+
137
+ ```text
138
+ cases/basics.py::sorted({'b', 'a'}) -> list[str] [runtime]
139
+ cases/basics.py::sorted({'b', 'a'}) -> list[str] [static: mypy]
140
+ cases/basics.py::sorted({'b', 'a'}) -> list[str] [static: pyright]
141
+ ```
142
+
143
+ The runtime test is not repeated, since the value does not depend on who checked it.
144
+ Bear in mind that two checkers do not always infer the same type for the same
145
+ expression, so a case that satisfies one may need rewording to satisfy both.
146
+
147
+ `ty` is deliberately not supported yet: it is pre-1.0 and its output format is still
148
+ moving. Adding a backend is a single module — see `type_assert/_checkers/`.
149
+
150
+ ## Skipping a case at runtime
151
+
152
+ A case that cannot run everywhere — it crashes on a platform, or needs something that is
153
+ not always installed — is named in a `SKIP_RUNTIME` mapping in its own file:
154
+
155
+ ```python
156
+ SKIP_RUNTIME = {
157
+ 'expression exactly as written': 'why running it fails here',
158
+ }
159
+ ```
160
+
161
+ Only the runtime half is skipped; the checker still checks the case. The mapping is read
162
+ after the file's setup has run, so making an entry conditional is ordinary Python. An
163
+ entry naming an expression that no case makes fails the file's `setup` test, so a skip
164
+ cannot quietly outlive the case it was written for.
165
+
166
+ ## License
167
+
168
+ MIT
@@ -0,0 +1,17 @@
1
+ type_assert/__init__.py,sha256=FuZcdyVFXVPBO2RJk8tvTr0k3DpQlx3wGkaOWcW2tPE,1158
2
+ type_assert/_assertions.py,sha256=M1DnFu7p7UQpsyGLMha24Vdum0GRYWqOCvLLnD08Bzw,2331
3
+ type_assert/_cases.py,sha256=U_eOGHhegkqt66S6TFrZrWFissdwqEOJIr_fNga9L8c,6570
4
+ type_assert/_version.py,sha256=DAfDHBn0nskQX4XfJFqp5cZl3BnPTKK40xEbsCjp0-o,528
5
+ type_assert/plugin.py,sha256=XcP_TvV9ToS9_KtjbLBNu5yTS9hLQMQQoHRUhW7odBk,8886
6
+ type_assert/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ type_assert/_checkers/__init__.py,sha256=OlLu4fy1A5FyxB_noPqjhkIsUyOGWmhra1QqQuxYtD8,1211
8
+ type_assert/_checkers/_base.py,sha256=a_1j88gWylAfdlsHS6G9S2IYInnxzRvyD8HFGdSaQqo,1572
9
+ type_assert/_checkers/_mypy.py,sha256=bUV40-8CbRJkrOXMfQvj53UcL-SX4Rix60qNrlnBNeY,3124
10
+ type_assert/_checkers/_pyrefly.py,sha256=dLWxnDxywv_l6xYngZowZPDCvbpzXUyah5XA01YBE2g,3683
11
+ type_assert/_checkers/_pyright.py,sha256=IomrJzqKMb6dItI2fbOHdAUo31CA6D47Z_onHSkaK8E,3427
12
+ type_assert-0.1.0.dist-info/licenses/LICENSE,sha256=8NfcALclfVO78lnzIfZVRlCn2rNGBfs1EFyHKF7gvfM,1070
13
+ type_assert-0.1.0.dist-info/METADATA,sha256=_YITpK_vjl0oC0rjh-0OQ9Rk0Thj6UxT4KPuFzW0wT0,5913
14
+ type_assert-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
+ type_assert-0.1.0.dist-info/entry_points.txt,sha256=7HmS--0q4AtdGVGlBbP-ZnYk3TV7C9YMP0TW1TZdscM,44
16
+ type_assert-0.1.0.dist-info/top_level.txt,sha256=yzHvUttBFBti_BE8_lMgvZ7rblwz2p49SbhN6gc9dMU,12
17
+ type_assert-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ type_assert = type_assert.plugin
@@ -0,0 +1,21 @@
1
+ The MIT License
2
+
3
+ Copyright (c) 2026 user27182
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ type_assert