type-assert 0.1.0__tar.gz → 0.2.1__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.
- {type_assert-0.1.0 → type_assert-0.2.1}/PKG-INFO +69 -1
- {type_assert-0.1.0 → type_assert-0.2.1}/README.md +68 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/pyproject.toml +1 -1
- {type_assert-0.1.0 → type_assert-0.2.1}/tests/test_assertions.py +124 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/tests/test_cases.py +46 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/tests/test_plugin.py +57 -0
- type_assert-0.2.1/type_assert/_assertions.py +94 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_cases.py +43 -2
- type_assert-0.2.1/type_assert/_exact.py +165 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_version.py +3 -3
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/plugin.py +25 -5
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/PKG-INFO +69 -1
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/SOURCES.txt +1 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/scm_file_list.json +1 -0
- type_assert-0.2.1/type_assert.egg-info/scm_version.json +8 -0
- type_assert-0.1.0/type_assert/_assertions.py +0 -55
- type_assert-0.1.0/type_assert.egg-info/scm_version.json +0 -8
- {type_assert-0.1.0 → type_assert-0.2.1}/.github/dependabot.yml +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/.github/release.yml +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/.github/workflows/ci.yml +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/.gitignore +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/.pre-commit-config.yaml +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/LICENSE +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/setup.cfg +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/tests/conftest.py +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/tests/test_checkers.py +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/tools/check_without_checker.py +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/__init__.py +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_checkers/__init__.py +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_checkers/_base.py +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_checkers/_mypy.py +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_checkers/_pyrefly.py +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_checkers/_pyright.py +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/py.typed +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/dependency_links.txt +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/entry_points.txt +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/requires.txt +0 -0
- {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: type-assert
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: pytest plugin that checks a value static type and its runtime type in one assertion
|
|
5
5
|
Author: user27182
|
|
6
6
|
License-Expression: MIT
|
|
@@ -122,6 +122,62 @@ it catches a `None` at any position in a `list[int]`, not only the first element
|
|
|
122
122
|
|
|
123
123
|
Writing the type once covers both halves, and there is no way for them to drift apart.
|
|
124
124
|
|
|
125
|
+
## What each half checks
|
|
126
|
+
|
|
127
|
+
The checker's `assert_type` is exact: the inferred type must be the expected type, so
|
|
128
|
+
`object` fails for an `int` and `list[float]` fails for a `list[int]`. The runtime
|
|
129
|
+
check is assignability, the relation the type system itself uses for a value: an
|
|
130
|
+
instance of a subclass passes for its base class, and every element of a container is
|
|
131
|
+
held to the same rule.
|
|
132
|
+
|
|
133
|
+
Two things are read more strictly than the type system would, because they are where a
|
|
134
|
+
declared type and a produced value drift apart in practice:
|
|
135
|
+
|
|
136
|
+
- A number has to be an instance of the numeric class named. An `int` does not pass for
|
|
137
|
+
`float`, a `bool` does not pass for `int`, and `[1, 2]` does not pass for
|
|
138
|
+
`list[float]`. A function that really returns either should say `float | int`.
|
|
139
|
+
- A NumPy array is checked as the array type it actually is, dtype and number of
|
|
140
|
+
dimensions included, so a `float64` array does not pass for `NDArray[np.int64]`
|
|
141
|
+
and a 1-D array does not pass for `ndarray[tuple[int, int], ...]`.
|
|
142
|
+
|
|
143
|
+
Both apply at any depth inside lists, tuples, sets and dicts. What the runtime half
|
|
144
|
+
cannot check is a type argument the value does not carry: a `Box[int]` is only a `Box`
|
|
145
|
+
at runtime, and there the checker is the sole authority. A supertype still fails only
|
|
146
|
+
the static half, which is the intended division of labour: the checker guards what was
|
|
147
|
+
declared, the run guards what was produced, and a case passes only when the declaration
|
|
148
|
+
is exact and the value honours it.
|
|
149
|
+
|
|
150
|
+
## Types that only a checker can spell
|
|
151
|
+
|
|
152
|
+
Some types have no runtime spelling: a name imported under `TYPE_CHECKING`, or a class
|
|
153
|
+
a checker treats as generic that cannot be subscripted at runtime, such as
|
|
154
|
+
`np.dtype[np.generic[object]]`. Write the type as a string, the way an annotation can be
|
|
155
|
+
quoted:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
assert_types(dtype_of(array), 'np.dtype[np.generic[object]]')
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The checker reads the string as the type it names and holds the case to it exactly. At
|
|
162
|
+
runtime the string is evaluated in the case file's namespace. When that succeeds the
|
|
163
|
+
value is checked against it as usual, so a wrong quoted type still fails both halves.
|
|
164
|
+
When the type cannot be built, the runtime half is skipped with the reason, since there
|
|
165
|
+
is nothing to check the value against.
|
|
166
|
+
|
|
167
|
+
To keep a runtime check as well, name the type under `TYPE_CHECKING` and give it a
|
|
168
|
+
runtime stand-in:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
if TYPE_CHECKING:
|
|
172
|
+
DType = np.dtype[np.generic[object]]
|
|
173
|
+
else:
|
|
174
|
+
DType = np.dtype
|
|
175
|
+
|
|
176
|
+
assert_types(dtype_of(array), DType)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The checker still sees the exact type; the value is checked against the stand-in.
|
|
180
|
+
|
|
125
181
|
## Choosing a checker
|
|
126
182
|
|
|
127
183
|
```toml
|
|
@@ -163,6 +219,18 @@ after the file's setup has run, so making an entry conditional is ordinary Pytho
|
|
|
163
219
|
entry naming an expression that no case makes fails the file's `setup` test, so a skip
|
|
164
220
|
cannot quietly outlive the case it was written for.
|
|
165
221
|
|
|
222
|
+
## Running the cases on their own
|
|
223
|
+
|
|
224
|
+
Case files collect like any other test file, so a job can run just them:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
pytest tests/typing/cases --no-cov
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
pytest-cov's `--no-cov` matters when the project sets a coverage threshold in
|
|
231
|
+
`addopts`: the cases exercise only what they call, so `--cov-fail-under` would fail a
|
|
232
|
+
run that is only about types. Runs of the whole suite are unaffected.
|
|
233
|
+
|
|
166
234
|
## License
|
|
167
235
|
|
|
168
236
|
MIT
|
|
@@ -82,6 +82,62 @@ it catches a `None` at any position in a `list[int]`, not only the first element
|
|
|
82
82
|
|
|
83
83
|
Writing the type once covers both halves, and there is no way for them to drift apart.
|
|
84
84
|
|
|
85
|
+
## What each half checks
|
|
86
|
+
|
|
87
|
+
The checker's `assert_type` is exact: the inferred type must be the expected type, so
|
|
88
|
+
`object` fails for an `int` and `list[float]` fails for a `list[int]`. The runtime
|
|
89
|
+
check is assignability, the relation the type system itself uses for a value: an
|
|
90
|
+
instance of a subclass passes for its base class, and every element of a container is
|
|
91
|
+
held to the same rule.
|
|
92
|
+
|
|
93
|
+
Two things are read more strictly than the type system would, because they are where a
|
|
94
|
+
declared type and a produced value drift apart in practice:
|
|
95
|
+
|
|
96
|
+
- A number has to be an instance of the numeric class named. An `int` does not pass for
|
|
97
|
+
`float`, a `bool` does not pass for `int`, and `[1, 2]` does not pass for
|
|
98
|
+
`list[float]`. A function that really returns either should say `float | int`.
|
|
99
|
+
- A NumPy array is checked as the array type it actually is, dtype and number of
|
|
100
|
+
dimensions included, so a `float64` array does not pass for `NDArray[np.int64]`
|
|
101
|
+
and a 1-D array does not pass for `ndarray[tuple[int, int], ...]`.
|
|
102
|
+
|
|
103
|
+
Both apply at any depth inside lists, tuples, sets and dicts. What the runtime half
|
|
104
|
+
cannot check is a type argument the value does not carry: a `Box[int]` is only a `Box`
|
|
105
|
+
at runtime, and there the checker is the sole authority. A supertype still fails only
|
|
106
|
+
the static half, which is the intended division of labour: the checker guards what was
|
|
107
|
+
declared, the run guards what was produced, and a case passes only when the declaration
|
|
108
|
+
is exact and the value honours it.
|
|
109
|
+
|
|
110
|
+
## Types that only a checker can spell
|
|
111
|
+
|
|
112
|
+
Some types have no runtime spelling: a name imported under `TYPE_CHECKING`, or a class
|
|
113
|
+
a checker treats as generic that cannot be subscripted at runtime, such as
|
|
114
|
+
`np.dtype[np.generic[object]]`. Write the type as a string, the way an annotation can be
|
|
115
|
+
quoted:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
assert_types(dtype_of(array), 'np.dtype[np.generic[object]]')
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The checker reads the string as the type it names and holds the case to it exactly. At
|
|
122
|
+
runtime the string is evaluated in the case file's namespace. When that succeeds the
|
|
123
|
+
value is checked against it as usual, so a wrong quoted type still fails both halves.
|
|
124
|
+
When the type cannot be built, the runtime half is skipped with the reason, since there
|
|
125
|
+
is nothing to check the value against.
|
|
126
|
+
|
|
127
|
+
To keep a runtime check as well, name the type under `TYPE_CHECKING` and give it a
|
|
128
|
+
runtime stand-in:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
if TYPE_CHECKING:
|
|
132
|
+
DType = np.dtype[np.generic[object]]
|
|
133
|
+
else:
|
|
134
|
+
DType = np.dtype
|
|
135
|
+
|
|
136
|
+
assert_types(dtype_of(array), DType)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The checker still sees the exact type; the value is checked against the stand-in.
|
|
140
|
+
|
|
85
141
|
## Choosing a checker
|
|
86
142
|
|
|
87
143
|
```toml
|
|
@@ -123,6 +179,18 @@ after the file's setup has run, so making an entry conditional is ordinary Pytho
|
|
|
123
179
|
entry naming an expression that no case makes fails the file's `setup` test, so a skip
|
|
124
180
|
cannot quietly outlive the case it was written for.
|
|
125
181
|
|
|
182
|
+
## Running the cases on their own
|
|
183
|
+
|
|
184
|
+
Case files collect like any other test file, so a job can run just them:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
pytest tests/typing/cases --no-cov
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
pytest-cov's `--no-cov` matters when the project sets a coverage threshold in
|
|
191
|
+
`addopts`: the cases exercise only what they call, so `--cov-fail-under` would fail a
|
|
192
|
+
run that is only about types. Runs of the whole suite are unaffected.
|
|
193
|
+
|
|
126
194
|
## License
|
|
127
195
|
|
|
128
196
|
MIT
|
|
@@ -4,7 +4,7 @@ requires = ['setuptools>=64', 'setuptools_scm>=8']
|
|
|
4
4
|
|
|
5
5
|
[dependency-groups]
|
|
6
6
|
dev = ['pre-commit', 'pytest-cov', { include-group = 'test' }]
|
|
7
|
-
test = ['mypy', 'pyrefly', 'pyright', 'pytest']
|
|
7
|
+
test = ['mypy', 'numpy', 'pyrefly', 'pyright', 'pytest']
|
|
8
8
|
|
|
9
9
|
[project]
|
|
10
10
|
authors = [{ name = 'user27182' }]
|
|
@@ -18,6 +18,8 @@ from typing import Protocol
|
|
|
18
18
|
from typing import Union
|
|
19
19
|
from typing import runtime_checkable
|
|
20
20
|
|
|
21
|
+
import numpy as np
|
|
22
|
+
import numpy.typing as npt
|
|
21
23
|
import pytest
|
|
22
24
|
|
|
23
25
|
from type_assert import assert_types
|
|
@@ -103,17 +105,139 @@ REJECTED = [
|
|
|
103
105
|
]
|
|
104
106
|
|
|
105
107
|
|
|
108
|
+
# Assignable to the type system, and rejected here all the same.
|
|
109
|
+
PROMOTED = [
|
|
110
|
+
pytest.param(1, float, id='int-is-not-float'),
|
|
111
|
+
pytest.param(True, float, id='bool-is-not-float'),
|
|
112
|
+
pytest.param(True, int, id='bool-is-not-int'),
|
|
113
|
+
pytest.param(1, complex, id='int-is-not-complex'),
|
|
114
|
+
pytest.param(1.5, complex, id='float-is-not-complex'),
|
|
115
|
+
pytest.param([1, 2], list[float], id='list-of-int-is-not-list-of-float'),
|
|
116
|
+
pytest.param([1.5, 1], list[float], id='promoted-element-last'),
|
|
117
|
+
pytest.param((1.5, 1), tuple[float, float], id='fixed-tuple-member'),
|
|
118
|
+
pytest.param((1.5, 1), tuple[float, ...], id='variadic-tuple-member'),
|
|
119
|
+
pytest.param({1.5, 1}, set[float], id='set-member'),
|
|
120
|
+
pytest.param({'a': 1}, dict[str, float], id='dict-value'),
|
|
121
|
+
pytest.param({1: 'a'}, dict[float, str], id='dict-key'),
|
|
122
|
+
pytest.param([[1.5], [1]], list[list[float]], id='nested'),
|
|
123
|
+
pytest.param([1], Sequence[float], id='abc-sequence'),
|
|
124
|
+
pytest.param(1, Optional[float], id='optional'),
|
|
125
|
+
pytest.param(1, float | None, id='optional-pep604'),
|
|
126
|
+
pytest.param(1, float | str, id='no-union-member-fits-exactly'),
|
|
127
|
+
pytest.param([1.5, 1], list[float | str], id='union-element'),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
# The stricter reading still lets through what it should.
|
|
131
|
+
STILL_ACCEPTED = [
|
|
132
|
+
pytest.param(1.5, float, id='float-is-float'),
|
|
133
|
+
pytest.param(1, int, id='int-is-int'),
|
|
134
|
+
pytest.param(True, bool, id='bool-is-bool'),
|
|
135
|
+
pytest.param(1j, complex, id='complex-is-complex'),
|
|
136
|
+
pytest.param(1, float | int, id='union-names-the-class'),
|
|
137
|
+
pytest.param(1, int | float, id='union-in-either-order'),
|
|
138
|
+
pytest.param(None, float | None, id='none-for-optional-float'),
|
|
139
|
+
pytest.param([1.5, 2.5], list[float], id='list-of-float'),
|
|
140
|
+
pytest.param((1.5, 1), tuple[float, int], id='fixed-tuple-as-declared'),
|
|
141
|
+
pytest.param({'a': 1.5}, dict[str, float], id='dict-as-declared'),
|
|
142
|
+
pytest.param([[1.5]], list[list[float]], id='nested-as-declared'),
|
|
143
|
+
pytest.param(np.float64(1.5), float, id='numpy-float64-is-a-float'),
|
|
144
|
+
pytest.param(1, Any, id='any'),
|
|
145
|
+
pytest.param(1, object, id='object'),
|
|
146
|
+
pytest.param('a', Literal['a', 'b'], id='literal'),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
106
150
|
@pytest.mark.parametrize(('value', 'expected'), ACCEPTED)
|
|
107
151
|
def test_accepts(value, expected):
|
|
108
152
|
assert_types(value, expected)
|
|
109
153
|
|
|
110
154
|
|
|
155
|
+
@pytest.mark.parametrize(('value', 'expected'), PROMOTED)
|
|
156
|
+
def test_rejects_what_the_type_system_would_merely_promote(value, expected):
|
|
157
|
+
with pytest.raises(AssertionError, match='does not have the expected type'):
|
|
158
|
+
assert_types(value, expected)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@pytest.mark.parametrize(('value', 'expected'), STILL_ACCEPTED)
|
|
162
|
+
def test_the_stricter_reading_accepts_what_is_declared(value, expected):
|
|
163
|
+
assert_types(value, expected)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_the_stricter_failure_names_where_the_problem_is():
|
|
167
|
+
with pytest.raises(AssertionError) as error:
|
|
168
|
+
assert_types({'a': [1.5, 1]}, dict[str, list[float]])
|
|
169
|
+
assert "value['a'][1] is int 1, not float" in str(error.value)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TestArrays:
|
|
173
|
+
"""A NumPy array is checked as the array type it is, dtype and dimensions included."""
|
|
174
|
+
|
|
175
|
+
def test_the_dtype_is_checked(self):
|
|
176
|
+
assert_types(np.array([1.0]), npt.NDArray[np.float64])
|
|
177
|
+
with pytest.raises(AssertionError, match='dtype float64'):
|
|
178
|
+
assert_types(np.array([1.0]), npt.NDArray[np.int64])
|
|
179
|
+
|
|
180
|
+
def test_a_union_of_dtypes_accepts_each_member(self):
|
|
181
|
+
assert_types(np.array([1], dtype=np.float32), npt.NDArray[np.float32 | np.float64])
|
|
182
|
+
with pytest.raises(AssertionError, match='does not have the expected type'):
|
|
183
|
+
assert_types(np.array([1]), npt.NDArray[np.float32 | np.float64])
|
|
184
|
+
|
|
185
|
+
def test_a_union_of_array_types_accepts_each_member(self):
|
|
186
|
+
assert_types(np.array([1]), npt.NDArray[np.float64] | npt.NDArray[np.int64])
|
|
187
|
+
|
|
188
|
+
def test_the_number_of_dimensions_is_checked(self):
|
|
189
|
+
matrix = np.ndarray[tuple[int, int], np.dtype[np.float64]]
|
|
190
|
+
assert_types(np.zeros((2, 2)), matrix)
|
|
191
|
+
with pytest.raises(AssertionError, match='1 dimension'):
|
|
192
|
+
assert_types(np.zeros(2), matrix)
|
|
193
|
+
|
|
194
|
+
def test_a_scalar_array_has_no_dimensions(self):
|
|
195
|
+
assert_types(np.array(1.0), np.ndarray[tuple[()], np.dtype[np.float64]])
|
|
196
|
+
assert_types(np.array(1.0), npt.NDArray[np.float64])
|
|
197
|
+
|
|
198
|
+
def test_an_unparametrised_array_type_accepts_any_array(self):
|
|
199
|
+
assert_types(np.array([1.0]), np.ndarray)
|
|
200
|
+
|
|
201
|
+
def test_arrays_inside_containers_are_checked(self):
|
|
202
|
+
assert_types([np.array([1])], list[npt.NDArray[np.int64]])
|
|
203
|
+
with pytest.raises(AssertionError, match=r'value\[0\] is an array of dtype float64'):
|
|
204
|
+
assert_types([np.array([1.0])], list[npt.NDArray[np.int64]])
|
|
205
|
+
|
|
206
|
+
def test_a_numpy_scalar_is_not_a_python_int(self):
|
|
207
|
+
# Not a promotion: np.int64 does not subclass int, so pycroscope rejects it.
|
|
208
|
+
with pytest.raises(AssertionError, match='not assignable'):
|
|
209
|
+
assert_types(np.int64(1), int)
|
|
210
|
+
|
|
211
|
+
|
|
111
212
|
@pytest.mark.parametrize(('value', 'expected'), REJECTED)
|
|
112
213
|
def test_rejects(value, expected):
|
|
113
214
|
with pytest.raises(AssertionError, match='not assignable'):
|
|
114
215
|
assert_types(value, expected)
|
|
115
216
|
|
|
116
217
|
|
|
218
|
+
class TestQuotedTypes:
|
|
219
|
+
"""A type written as a string is built where the assertion is made."""
|
|
220
|
+
|
|
221
|
+
def test_a_quoted_type_is_checked_rather_than_taken_for_any(self):
|
|
222
|
+
assert_types(1, 'int')
|
|
223
|
+
with pytest.raises(AssertionError, match='not assignable'):
|
|
224
|
+
assert_types('x', 'int')
|
|
225
|
+
|
|
226
|
+
def test_a_quoted_container_type_is_walked(self):
|
|
227
|
+
with pytest.raises(AssertionError, match='not assignable'):
|
|
228
|
+
assert_types([1, 'a'], 'list[int]')
|
|
229
|
+
|
|
230
|
+
def test_names_local_to_the_caller_are_visible(self):
|
|
231
|
+
class Local:
|
|
232
|
+
"""Exists only inside this test."""
|
|
233
|
+
|
|
234
|
+
assert_types(Local(), 'Local')
|
|
235
|
+
|
|
236
|
+
def test_a_type_that_cannot_be_built_is_an_error_not_a_pass(self):
|
|
237
|
+
with pytest.raises(TypeError, match=r'cannot be built at runtime \(NameError'):
|
|
238
|
+
assert_types(1, 'NoSuchName')
|
|
239
|
+
|
|
240
|
+
|
|
117
241
|
def test_returns_the_value_unchanged():
|
|
118
242
|
value = [1, 2]
|
|
119
243
|
assert assert_types(value, list[int]) is value
|
|
@@ -163,6 +163,52 @@ class TestMalformed:
|
|
|
163
163
|
assert case_file.cases == ()
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
class TestQuotedTypes:
|
|
167
|
+
"""A quoted expected type reads like an unquoted one, and may exist only for a checker."""
|
|
168
|
+
|
|
169
|
+
NEVER = (
|
|
170
|
+
'from typing import TYPE_CHECKING\n\n'
|
|
171
|
+
'if TYPE_CHECKING:\n'
|
|
172
|
+
' from typing_extensions import Never\n\n\n'
|
|
173
|
+
'def empty() -> list[Never]:\n'
|
|
174
|
+
' return []\n\n\n'
|
|
175
|
+
"assert_types(empty(), 'list[Never]')\n"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def test_the_id_drops_the_quotes(self, write):
|
|
179
|
+
case_file = write("assert_types(len([1]), 'int')\n")
|
|
180
|
+
assert case_file.cases[0].id == 'len([1]) -> int'
|
|
181
|
+
assert case_file.cases[0].quoted
|
|
182
|
+
|
|
183
|
+
def test_an_unquoted_type_is_not_marked_quoted(self, write):
|
|
184
|
+
case_file = write('assert_types(len([1]), int)\n')
|
|
185
|
+
assert not case_file.cases[0].quoted
|
|
186
|
+
|
|
187
|
+
def test_the_spelling_inside_the_quotes_is_normalised(self, write):
|
|
188
|
+
case_file = write("assert_types(len([1]), 'list[ int ]')\n")
|
|
189
|
+
assert case_file.cases[0].expected == 'list[int]'
|
|
190
|
+
|
|
191
|
+
def test_a_type_that_exists_at_runtime_is_checked(self, write):
|
|
192
|
+
case_file = write("assert_types(len([1]), 'str')\n")
|
|
193
|
+
with pytest.raises(AssertionError, match='not assignable'):
|
|
194
|
+
case_file.run(case_file.cases[0])
|
|
195
|
+
|
|
196
|
+
def test_a_type_that_cannot_be_built_skips_the_runtime_half(self, write):
|
|
197
|
+
case_file = write(self.NEVER)
|
|
198
|
+
with pytest.raises(CaseSkipped, match=r"'list\[Never\]' cannot be built at runtime"):
|
|
199
|
+
case_file.run(case_file.cases[0])
|
|
200
|
+
|
|
201
|
+
def test_the_skip_names_the_error(self, write):
|
|
202
|
+
case_file = write(self.NEVER)
|
|
203
|
+
with pytest.raises(CaseSkipped, match='NameError'):
|
|
204
|
+
case_file.run(case_file.cases[0])
|
|
205
|
+
|
|
206
|
+
def test_a_quoted_type_that_is_not_an_expression_is_malformed(self, write):
|
|
207
|
+
case_file = write("assert_types(len([1]), 'not a type')\n")
|
|
208
|
+
assert case_file.error is not None
|
|
209
|
+
assert 'not a type expression' in case_file.error
|
|
210
|
+
|
|
211
|
+
|
|
166
212
|
class TestSkipping:
|
|
167
213
|
"""`SKIP_RUNTIME` takes the runtime half out, and only that."""
|
|
168
214
|
|
|
@@ -88,6 +88,20 @@ def test_a_wrong_runtime_value_fails_the_runtime_half(project):
|
|
|
88
88
|
result.assert_outcomes(passed=3)
|
|
89
89
|
|
|
90
90
|
|
|
91
|
+
def test_a_value_the_type_system_would_promote_fails_the_runtime_half(project):
|
|
92
|
+
# Returning an int for a declared float satisfies every checker, so only the
|
|
93
|
+
# runtime half can say that the declaration does not match what is produced.
|
|
94
|
+
source = (
|
|
95
|
+
'from type_assert import assert_types\n\n'
|
|
96
|
+
'def rounded() -> float:\n'
|
|
97
|
+
' return 1\n\n'
|
|
98
|
+
'assert_types(rounded(), float)\n'
|
|
99
|
+
)
|
|
100
|
+
result = project(source).runpytest()
|
|
101
|
+
result.assert_outcomes(passed=2, failed=1)
|
|
102
|
+
result.stdout.fnmatch_lines(['*runtime*', '*is int 1, not float*'])
|
|
103
|
+
|
|
104
|
+
|
|
91
105
|
def test_an_error_outside_a_case_fails_the_setup_test(project):
|
|
92
106
|
source = (
|
|
93
107
|
'from type_assert import assert_types\n\nBAD: int = "no"\nassert_types(len([1]), int)\n'
|
|
@@ -132,6 +146,49 @@ def test_a_skipped_case_skips_only_its_runtime_half(project):
|
|
|
132
146
|
result.stdout.fnmatch_lines(['*a reason*'])
|
|
133
147
|
|
|
134
148
|
|
|
149
|
+
QUOTED_NEVER = (
|
|
150
|
+
'from typing import TYPE_CHECKING\n\n'
|
|
151
|
+
'from type_assert import assert_types\n\n'
|
|
152
|
+
'if TYPE_CHECKING:\n'
|
|
153
|
+
' from typing_extensions import Never\n\n\n'
|
|
154
|
+
'def empty() -> list[Never]:\n'
|
|
155
|
+
' return []\n\n\n'
|
|
156
|
+
"assert_types(empty(), 'list[Never]')\n"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.parametrize('checker', ['mypy', 'pyright'])
|
|
161
|
+
def test_a_quoted_type_only_a_checker_can_build_is_checked_statically(project, checker):
|
|
162
|
+
result = project(QUOTED_NEVER, checkers=checker).runpytest('-rs')
|
|
163
|
+
result.assert_outcomes(passed=2, skipped=1)
|
|
164
|
+
result.stdout.fnmatch_lines(['*cannot be built at runtime*'])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_a_case_file_named_on_the_command_line_is_collected_once_as_cases(project):
|
|
168
|
+
# pytest collects an explicitly named `.py` file as a test module whatever its
|
|
169
|
+
# name; importing this one would run the quoted assertion at import and fail.
|
|
170
|
+
result = project(QUOTED_NEVER).runpytest('cases/sample.py', '-rs')
|
|
171
|
+
result.assert_outcomes(passed=2, skipped=1, errors=0)
|
|
172
|
+
result.stdout.fnmatch_lines(['*cannot be built at runtime*'])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_a_case_file_named_on_the_command_line_yields_the_same_tests(project):
|
|
176
|
+
result = project(CLEAN).runpytest('cases/sample.py', '--collect-only', '-q')
|
|
177
|
+
result.stdout.fnmatch_lines(['*::setup', '*len([[]1[]]) -> int [[]runtime[]]'])
|
|
178
|
+
assert result.stdout.str().count('[runtime]') == 2
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_a_quoted_type_the_runtime_can_build_is_held_to_by_both_halves(project):
|
|
182
|
+
source = (
|
|
183
|
+
'from type_assert import assert_types\n\n'
|
|
184
|
+
"assert_types(len([1]), 'int')\n"
|
|
185
|
+
"assert_types(len([1]), 'str')\n"
|
|
186
|
+
)
|
|
187
|
+
result = project(source).runpytest()
|
|
188
|
+
# The right type passes both halves and the wrong one fails both: quoting weakens neither.
|
|
189
|
+
result.assert_outcomes(passed=3, failed=2)
|
|
190
|
+
|
|
191
|
+
|
|
135
192
|
def test_a_skip_naming_no_case_fails_the_setup_test(project):
|
|
136
193
|
source = (
|
|
137
194
|
'from type_assert import assert_types\n\n'
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
The runtime check is assignability, read strictly in two places where a declared
|
|
7
|
+
type and a produced value drift apart in practice: a number has to be an instance
|
|
8
|
+
of the numeric class named, so an `int` does not pass for `float`, and a NumPy
|
|
9
|
+
array is checked as the array type it actually is, dtype and dimensions included.
|
|
10
|
+
|
|
11
|
+
The expected type may be quoted, as annotations may. A checker reads the string as
|
|
12
|
+
the type it names; at runtime the string is evaluated in the caller's namespace, so
|
|
13
|
+
it means the same thing to both halves.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import functools
|
|
19
|
+
import sys
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from types import FrameType
|
|
25
|
+
|
|
26
|
+
# A checker resolves an aliased import back to its original definition, so it
|
|
27
|
+
# applies its `assert_type` special case here: the inferred type must match
|
|
28
|
+
# `expected` *exactly*, not merely be assignable to it. Verified against both
|
|
29
|
+
# mypy and pyright. At runtime the definition below runs instead and checks the
|
|
30
|
+
# value, so one call covers both halves and they cannot drift apart.
|
|
31
|
+
from typing_extensions import assert_type as assert_types
|
|
32
|
+
else:
|
|
33
|
+
from pycroscope.checker import Checker
|
|
34
|
+
from pycroscope.runtime import CanAssignError
|
|
35
|
+
from pycroscope.runtime import KnownValue
|
|
36
|
+
from pycroscope.runtime import Relation
|
|
37
|
+
from pycroscope.runtime import has_relation
|
|
38
|
+
from pycroscope.runtime import type_from_runtime
|
|
39
|
+
|
|
40
|
+
from ._exact import mismatch
|
|
41
|
+
|
|
42
|
+
@functools.cache
|
|
43
|
+
def _checker() -> Checker:
|
|
44
|
+
"""Return the shared checker, built on first use."""
|
|
45
|
+
return Checker()
|
|
46
|
+
|
|
47
|
+
def assert_types(value: object, expected: Any) -> object:
|
|
48
|
+
"""Assert `value` is assignable to `expected` at runtime, and return it."""
|
|
49
|
+
if isinstance(expected, str):
|
|
50
|
+
expected = _resolve(expected, sys._getframe(1))
|
|
51
|
+
# pycroscope's own `get_assignability_error` memoises against a module-global
|
|
52
|
+
# checker, which keeps every checked value alive for the rest of the session.
|
|
53
|
+
# Use our own so the memo can be dropped after each check.
|
|
54
|
+
checker = _checker()
|
|
55
|
+
try:
|
|
56
|
+
relation = has_relation(
|
|
57
|
+
type_from_runtime(expected), KnownValue(value), Relation.ASSIGNABLE, checker
|
|
58
|
+
)
|
|
59
|
+
if isinstance(relation, CanAssignError):
|
|
60
|
+
msg = (
|
|
61
|
+
f'Runtime value of type {type(value).__name__!r} is not assignable '
|
|
62
|
+
f'to the expected type:\n\t{expected}\n\n{relation.display(depth=0)}'
|
|
63
|
+
)
|
|
64
|
+
# An assertion that failed, not a caller passing the wrong kind of argument.
|
|
65
|
+
raise AssertionError(msg) # noqa: TRY004
|
|
66
|
+
problem = mismatch(value, expected, checker=checker)
|
|
67
|
+
finally:
|
|
68
|
+
cache = checker.get_relation_cache()
|
|
69
|
+
if cache is not None:
|
|
70
|
+
cache.clear()
|
|
71
|
+
|
|
72
|
+
if problem is not None:
|
|
73
|
+
msg = (
|
|
74
|
+
f'Runtime value of type {type(value).__name__!r} does not have the '
|
|
75
|
+
f'expected type:\n\t{expected}\n\n{problem}'
|
|
76
|
+
)
|
|
77
|
+
raise AssertionError(msg)
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
def _resolve(expected: str, frame: FrameType) -> object:
|
|
81
|
+
"""Build a quoted type in the caller's namespace, where a checker also reads it.
|
|
82
|
+
|
|
83
|
+
Left as a string, pycroscope would take it for an unresolvable forward
|
|
84
|
+
reference and check against `Any`, which passes for every value.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
return eval(expected, frame.f_globals, frame.f_locals)
|
|
88
|
+
except Exception as error:
|
|
89
|
+
msg = (
|
|
90
|
+
f'The expected type {expected!r} cannot be built at runtime '
|
|
91
|
+
f'({type(error).__name__}: {error}). A case file skips the runtime half '
|
|
92
|
+
f'of such a case; elsewhere, spell a type that exists at runtime.'
|
|
93
|
+
)
|
|
94
|
+
raise TypeError(msg) from error
|
|
@@ -32,6 +32,9 @@ class Case:
|
|
|
32
32
|
expression: str
|
|
33
33
|
expected: str
|
|
34
34
|
code: CodeType
|
|
35
|
+
#: Whether the expected type was written as a string. A checker reads it the same
|
|
36
|
+
#: way; at runtime it is built in the file's namespace, which may not be possible.
|
|
37
|
+
quoted: bool = False
|
|
35
38
|
|
|
36
39
|
@property
|
|
37
40
|
def id(self) -> str:
|
|
@@ -68,7 +71,7 @@ class CaseFile:
|
|
|
68
71
|
cannot observe another's state, and reordering the file changes nothing.
|
|
69
72
|
"""
|
|
70
73
|
namespace = self.setup_namespace()
|
|
71
|
-
reason = skip_reason(namespace, case)
|
|
74
|
+
reason = skip_reason(namespace, case) or unbuildable_reason(namespace, case)
|
|
72
75
|
if reason is not None:
|
|
73
76
|
raise CaseSkipped(reason)
|
|
74
77
|
exec(case.code, namespace) # noqa: S102
|
|
@@ -92,6 +95,26 @@ def skip_reason(namespace: dict[str, Any], case: Case) -> str | None:
|
|
|
92
95
|
return declared.get(case.expression) or None
|
|
93
96
|
|
|
94
97
|
|
|
98
|
+
def unbuildable_reason(namespace: dict[str, Any], case: Case) -> str | None:
|
|
99
|
+
"""Return why `case`'s quoted type cannot be built at runtime, if it cannot.
|
|
100
|
+
|
|
101
|
+
A type that exists only for a checker -- a name imported under `TYPE_CHECKING`,
|
|
102
|
+
a class that cannot be subscripted at runtime -- can still be written as a
|
|
103
|
+
string. The checker holds the case to it; the runtime half has nothing to check
|
|
104
|
+
against, so it is skipped rather than failed.
|
|
105
|
+
"""
|
|
106
|
+
if not case.quoted:
|
|
107
|
+
return None
|
|
108
|
+
try:
|
|
109
|
+
eval(case.expected, namespace)
|
|
110
|
+
except Exception as error: # noqa: BLE001
|
|
111
|
+
return (
|
|
112
|
+
f'the expected type {case.expected!r} cannot be built at runtime '
|
|
113
|
+
f'({type(error).__name__}: {error}), so only a checker can check this case'
|
|
114
|
+
)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
95
118
|
def _case_call(node: ast.AST) -> ast.Call | None:
|
|
96
119
|
"""Return the `assert_types` call a top-level statement makes, if it makes one."""
|
|
97
120
|
if not isinstance(node, ast.Expr):
|
|
@@ -157,14 +180,16 @@ def _parse_case_file(path: Path) -> CaseFile:
|
|
|
157
180
|
if len(call.args) != 2:
|
|
158
181
|
msg = f'{path.name}:{node.lineno}: `{ASSERTION}` takes an expression and a type.'
|
|
159
182
|
raise CaseError(msg)
|
|
183
|
+
expected, quoted = _expected_type(call.args[1], path, node.lineno)
|
|
160
184
|
module = ast.Module(body=[node], type_ignores=[])
|
|
161
185
|
cases.append(
|
|
162
186
|
Case(
|
|
163
187
|
path=path,
|
|
164
188
|
lines=frozenset(range(node.lineno, (node.end_lineno or node.lineno) + 1)),
|
|
165
189
|
expression=ast.unparse(call.args[0]),
|
|
166
|
-
expected=
|
|
190
|
+
expected=expected,
|
|
167
191
|
code=compile(ast.fix_missing_locations(module), str(path), 'exec'),
|
|
192
|
+
quoted=quoted,
|
|
168
193
|
)
|
|
169
194
|
)
|
|
170
195
|
|
|
@@ -177,6 +202,22 @@ def _parse_case_file(path: Path) -> CaseFile:
|
|
|
177
202
|
)
|
|
178
203
|
|
|
179
204
|
|
|
205
|
+
def _expected_type(node: ast.expr, path: Path, lineno: int) -> tuple[str, bool]:
|
|
206
|
+
"""Return the expected type as written, unquoted if it was a string, and whether it was.
|
|
207
|
+
|
|
208
|
+
Both spellings normalise to the same text, so a case reads the same in a test id
|
|
209
|
+
whichever way it was written.
|
|
210
|
+
"""
|
|
211
|
+
if not (isinstance(node, ast.Constant) and isinstance(node.value, str)):
|
|
212
|
+
return ast.unparse(node), False
|
|
213
|
+
try:
|
|
214
|
+
tree = ast.parse(node.value, mode='eval')
|
|
215
|
+
except SyntaxError:
|
|
216
|
+
msg = f'{path.name}:{lineno}: the quoted type {node.value!r} is not a type expression.'
|
|
217
|
+
raise CaseError(msg) from None
|
|
218
|
+
return ast.unparse(tree.body), True
|
|
219
|
+
|
|
220
|
+
|
|
180
221
|
def collect_cases(directory: Path) -> list[CaseFile]:
|
|
181
222
|
"""Parse every case file in `directory`."""
|
|
182
223
|
return [collect_case_file(path) for path in sorted(directory.glob('*.py'))]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The two ways the runtime half is stricter than plain assignability.
|
|
2
|
+
|
|
3
|
+
pycroscope decides whether a value is assignable to a type the way the type system
|
|
4
|
+
does, which lets an `int` pass for `float` and cannot see the type arguments of a
|
|
5
|
+
NumPy array. Both are places where a declared type and a produced value drift apart
|
|
6
|
+
in practice, so after pycroscope has accepted a value, `mismatch` walks it once more
|
|
7
|
+
against the expected type and rejects those two things: a number that is not an
|
|
8
|
+
instance of the numeric class named, and an array whose dtype or dimensionality is
|
|
9
|
+
not the one named. Anything it does not understand it leaves to pycroscope's verdict.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from collections.abc import MutableMapping
|
|
16
|
+
from collections.abc import MutableSequence
|
|
17
|
+
from collections.abc import MutableSet
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from collections.abc import Set as AbstractSet
|
|
20
|
+
import sys
|
|
21
|
+
from types import UnionType
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
from typing import Any
|
|
24
|
+
from typing import Union
|
|
25
|
+
from typing import get_args
|
|
26
|
+
from typing import get_origin
|
|
27
|
+
|
|
28
|
+
from pycroscope.runtime import CanAssignError
|
|
29
|
+
from pycroscope.runtime import KnownValue
|
|
30
|
+
from pycroscope.runtime import Relation
|
|
31
|
+
from pycroscope.runtime import has_relation
|
|
32
|
+
from pycroscope.runtime import type_from_runtime
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from pycroscope.checker import Checker
|
|
36
|
+
|
|
37
|
+
# Numeric classes a value has to be an instance of, rather than merely promotable to.
|
|
38
|
+
_NUMERIC = (float, int, complex)
|
|
39
|
+
_SEQUENCES = (list, set, frozenset, Sequence, MutableSequence, AbstractSet, MutableSet)
|
|
40
|
+
_MAPPINGS = (dict, Mapping, MutableMapping)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def mismatch(value: object, expected: Any, *, checker: Checker, path: str = 'value') -> str | None:
|
|
44
|
+
"""Return why `value` is not of `expected` under the stricter reading, or `None`.
|
|
45
|
+
|
|
46
|
+
Only called for a value pycroscope has already found assignable, so this never
|
|
47
|
+
has to accept anything; it only adds rejections, each naming where in the value
|
|
48
|
+
it found the problem.
|
|
49
|
+
"""
|
|
50
|
+
if expected is Any or expected is object:
|
|
51
|
+
return None
|
|
52
|
+
origin = get_origin(expected)
|
|
53
|
+
if origin is Union or origin is UnionType:
|
|
54
|
+
return _mismatch_union(value, expected, checker=checker, path=path)
|
|
55
|
+
if expected in _NUMERIC:
|
|
56
|
+
return _mismatch_number(value, expected, path=path)
|
|
57
|
+
if origin in _SEQUENCES and isinstance(value, (list, tuple, set, frozenset)):
|
|
58
|
+
(item_type,) = get_args(expected) or (Any,)
|
|
59
|
+
return _mismatch_items(value, [item_type] * len(value), checker=checker, path=path)
|
|
60
|
+
if origin is tuple and isinstance(value, tuple):
|
|
61
|
+
return _mismatch_tuple(value, expected, checker=checker, path=path)
|
|
62
|
+
if origin in _MAPPINGS and isinstance(value, dict):
|
|
63
|
+
return _mismatch_mapping(value, expected, checker=checker, path=path)
|
|
64
|
+
ndarray = _ndarray_class()
|
|
65
|
+
if ndarray is not None and isinstance(value, ndarray) and _is_array_type(expected, ndarray):
|
|
66
|
+
return _mismatch_array(value, expected, checker=checker, path=path)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _mismatch_union(value: object, expected: Any, *, checker: Checker, path: str) -> str | None:
|
|
71
|
+
"""Accept a union if some member accepts the value under both readings."""
|
|
72
|
+
for member in get_args(expected):
|
|
73
|
+
if _assignable(member, KnownValue(value), checker) and (
|
|
74
|
+
mismatch(value, member, checker=checker, path=path) is None
|
|
75
|
+
):
|
|
76
|
+
return None
|
|
77
|
+
return f'{path} is {_describe(value)}, which is not of any of {expected}'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _mismatch_number(value: object, expected: type, *, path: str) -> str | None:
|
|
81
|
+
"""Require an instance of the numeric class named, not one promotable to it."""
|
|
82
|
+
if isinstance(value, expected) and not (expected is int and isinstance(value, bool)):
|
|
83
|
+
return None
|
|
84
|
+
return f'{path} is {_describe(value)}, not {expected.__name__}'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _mismatch_items(items, item_types, *, checker: Checker, path: str) -> str | None:
|
|
88
|
+
"""Check each item of a sequence against its type."""
|
|
89
|
+
for index, (item, item_type) in enumerate(zip(items, item_types, strict=True)):
|
|
90
|
+
problem = mismatch(item, item_type, checker=checker, path=f'{path}[{index}]')
|
|
91
|
+
if problem is not None:
|
|
92
|
+
return problem
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _mismatch_tuple(value: tuple, expected: Any, *, checker: Checker, path: str) -> str | None:
|
|
97
|
+
"""Check a tuple item by item, whether it is fixed or variadic."""
|
|
98
|
+
args = get_args(expected)
|
|
99
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
100
|
+
return _mismatch_items(value, [args[0]] * len(value), checker=checker, path=path)
|
|
101
|
+
if args == ((),):
|
|
102
|
+
return None
|
|
103
|
+
# pycroscope has already matched the lengths.
|
|
104
|
+
return _mismatch_items(value, args, checker=checker, path=path)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _mismatch_mapping(value: dict, expected: Any, *, checker: Checker, path: str) -> str | None:
|
|
108
|
+
"""Check a mapping key by key and value by value."""
|
|
109
|
+
key_type, value_type = get_args(expected) or (Any, Any)
|
|
110
|
+
for key, item in value.items():
|
|
111
|
+
problem = mismatch(key, key_type, checker=checker, path=f'{path} key {key!r}')
|
|
112
|
+
if problem is not None:
|
|
113
|
+
return problem
|
|
114
|
+
problem = mismatch(item, value_type, checker=checker, path=f'{path}[{key!r}]')
|
|
115
|
+
if problem is not None:
|
|
116
|
+
return problem
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _mismatch_array(value: Any, expected: Any, *, checker: Checker, path: str) -> str | None:
|
|
121
|
+
"""Check an array as the array type it actually is.
|
|
122
|
+
|
|
123
|
+
A runtime array carries its dtype and shape, so unlike most generics its type
|
|
124
|
+
arguments can be recovered. pycroscope compares two array *types* argument by
|
|
125
|
+
argument, which it cannot do for the array itself.
|
|
126
|
+
"""
|
|
127
|
+
numpy = sys.modules['numpy']
|
|
128
|
+
shape = tuple[()] if value.ndim == 0 else tuple[(int,) * value.ndim] # type: ignore[misc]
|
|
129
|
+
actual = numpy.ndarray[shape, numpy.dtype[value.dtype.type]]
|
|
130
|
+
if _assignable(expected, type_from_runtime(actual), checker):
|
|
131
|
+
return None
|
|
132
|
+
return (
|
|
133
|
+
f'{path} is an array of dtype {value.dtype} with {value.ndim} dimension(s), '
|
|
134
|
+
f'which is not {expected}'
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _is_array_type(expected: Any, ndarray: type) -> bool:
|
|
139
|
+
"""Tell whether `expected` is a parametrised `ndarray`, also through `NDArray`.
|
|
140
|
+
|
|
141
|
+
`numpy.typing.NDArray` is a type alias, and on Python 3.12 and later a
|
|
142
|
+
subscripted alias reports the alias rather than `ndarray` as its origin.
|
|
143
|
+
"""
|
|
144
|
+
origin = get_origin(expected)
|
|
145
|
+
aliased = getattr(origin, '__value__', None)
|
|
146
|
+
if aliased is not None:
|
|
147
|
+
origin = get_origin(aliased)
|
|
148
|
+
return origin is ndarray
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _ndarray_class() -> type | None:
|
|
152
|
+
"""Return `numpy.ndarray` if NumPy has been imported, without importing it."""
|
|
153
|
+
numpy = sys.modules.get('numpy')
|
|
154
|
+
return None if numpy is None else numpy.ndarray
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _assignable(expected: Any, actual: Any, checker: Checker) -> bool:
|
|
158
|
+
"""Ask pycroscope whether `actual`, a Value, is assignable to `expected`, a type."""
|
|
159
|
+
relation = has_relation(type_from_runtime(expected), actual, Relation.ASSIGNABLE, checker)
|
|
160
|
+
return not isinstance(relation, CanAssignError)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _describe(value: object) -> str:
|
|
164
|
+
"""Name a value by its class and repr, the way the failure messages read."""
|
|
165
|
+
return f'{type(value).__name__} {value!r}'
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.1
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.2.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 1)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gc60ea7172'
|
|
@@ -46,9 +46,9 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
|
46
46
|
parser.addini(
|
|
47
47
|
CHECKERS_INI,
|
|
48
48
|
'Type checkers to check the cases with, whitespace separated. Any of: '
|
|
49
|
-
'
|
|
50
|
-
"case can be held to more than one checker's inference. Defaults
|
|
51
|
-
f'{" ".join(DEFAULT_CHECKERS)}.',
|
|
49
|
+
f'{", ".join(sorted(CHECKERS))}. Each one gets a static test of its own per '
|
|
50
|
+
"case, so a case can be held to more than one checker's inference. Defaults "
|
|
51
|
+
f'to {" ".join(DEFAULT_CHECKERS)}.',
|
|
52
52
|
type='args',
|
|
53
53
|
default=list(DEFAULT_CHECKERS),
|
|
54
54
|
)
|
|
@@ -85,14 +85,34 @@ def cases_dir(config: pytest.Config) -> Path | None:
|
|
|
85
85
|
return (Path(config.rootpath) / configured).resolve()
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def _is_case_file(file_path: Path, config: pytest.Config) -> bool:
|
|
89
|
+
"""Tell whether `file_path` is a `.py` file in the configured cases directory."""
|
|
90
|
+
directory = cases_dir(config)
|
|
91
|
+
return directory is not None and file_path.suffix == '.py' and file_path.parent == directory
|
|
92
|
+
|
|
93
|
+
|
|
88
94
|
def pytest_collect_file(file_path: Path, parent: pytest.Collector):
|
|
89
95
|
"""Collect a `.py` file in the configured cases directory as a case file."""
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
# A path named on the command line reaches `pytest_pycollect_makemodule` below,
|
|
97
|
+
# which is where pytest's own Python collector would otherwise pick it up.
|
|
98
|
+
if not _is_case_file(file_path, parent.config) or parent.session.isinitpath(file_path):
|
|
92
99
|
return None
|
|
93
100
|
return CaseFileCollector.from_parent(parent, path=file_path)
|
|
94
101
|
|
|
95
102
|
|
|
103
|
+
@pytest.hookimpl(tryfirst=True)
|
|
104
|
+
def pytest_pycollect_makemodule(module_path: Path, parent: pytest.Collector):
|
|
105
|
+
"""Collect a case file named on the command line as a case file, not a module.
|
|
106
|
+
|
|
107
|
+
pytest collects any `.py` path it is given explicitly as a test module whatever
|
|
108
|
+
its name, which would import the case file and run every assertion at import.
|
|
109
|
+
Answering this hook first hands the file to the case file collector instead.
|
|
110
|
+
"""
|
|
111
|
+
if not _is_case_file(module_path, parent.config):
|
|
112
|
+
return None
|
|
113
|
+
return CaseFileCollector.from_parent(parent, path=module_path)
|
|
114
|
+
|
|
115
|
+
|
|
96
116
|
def _diagnostics(config: pytest.Config, checker_name: str) -> dict[Path, list[Diagnostic]]:
|
|
97
117
|
"""Run one checker once per session and cache its result on the config."""
|
|
98
118
|
cache = getattr(config, _DIAGNOSTICS, None)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: type-assert
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: pytest plugin that checks a value static type and its runtime type in one assertion
|
|
5
5
|
Author: user27182
|
|
6
6
|
License-Expression: MIT
|
|
@@ -122,6 +122,62 @@ it catches a `None` at any position in a `list[int]`, not only the first element
|
|
|
122
122
|
|
|
123
123
|
Writing the type once covers both halves, and there is no way for them to drift apart.
|
|
124
124
|
|
|
125
|
+
## What each half checks
|
|
126
|
+
|
|
127
|
+
The checker's `assert_type` is exact: the inferred type must be the expected type, so
|
|
128
|
+
`object` fails for an `int` and `list[float]` fails for a `list[int]`. The runtime
|
|
129
|
+
check is assignability, the relation the type system itself uses for a value: an
|
|
130
|
+
instance of a subclass passes for its base class, and every element of a container is
|
|
131
|
+
held to the same rule.
|
|
132
|
+
|
|
133
|
+
Two things are read more strictly than the type system would, because they are where a
|
|
134
|
+
declared type and a produced value drift apart in practice:
|
|
135
|
+
|
|
136
|
+
- A number has to be an instance of the numeric class named. An `int` does not pass for
|
|
137
|
+
`float`, a `bool` does not pass for `int`, and `[1, 2]` does not pass for
|
|
138
|
+
`list[float]`. A function that really returns either should say `float | int`.
|
|
139
|
+
- A NumPy array is checked as the array type it actually is, dtype and number of
|
|
140
|
+
dimensions included, so a `float64` array does not pass for `NDArray[np.int64]`
|
|
141
|
+
and a 1-D array does not pass for `ndarray[tuple[int, int], ...]`.
|
|
142
|
+
|
|
143
|
+
Both apply at any depth inside lists, tuples, sets and dicts. What the runtime half
|
|
144
|
+
cannot check is a type argument the value does not carry: a `Box[int]` is only a `Box`
|
|
145
|
+
at runtime, and there the checker is the sole authority. A supertype still fails only
|
|
146
|
+
the static half, which is the intended division of labour: the checker guards what was
|
|
147
|
+
declared, the run guards what was produced, and a case passes only when the declaration
|
|
148
|
+
is exact and the value honours it.
|
|
149
|
+
|
|
150
|
+
## Types that only a checker can spell
|
|
151
|
+
|
|
152
|
+
Some types have no runtime spelling: a name imported under `TYPE_CHECKING`, or a class
|
|
153
|
+
a checker treats as generic that cannot be subscripted at runtime, such as
|
|
154
|
+
`np.dtype[np.generic[object]]`. Write the type as a string, the way an annotation can be
|
|
155
|
+
quoted:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
assert_types(dtype_of(array), 'np.dtype[np.generic[object]]')
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The checker reads the string as the type it names and holds the case to it exactly. At
|
|
162
|
+
runtime the string is evaluated in the case file's namespace. When that succeeds the
|
|
163
|
+
value is checked against it as usual, so a wrong quoted type still fails both halves.
|
|
164
|
+
When the type cannot be built, the runtime half is skipped with the reason, since there
|
|
165
|
+
is nothing to check the value against.
|
|
166
|
+
|
|
167
|
+
To keep a runtime check as well, name the type under `TYPE_CHECKING` and give it a
|
|
168
|
+
runtime stand-in:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
if TYPE_CHECKING:
|
|
172
|
+
DType = np.dtype[np.generic[object]]
|
|
173
|
+
else:
|
|
174
|
+
DType = np.dtype
|
|
175
|
+
|
|
176
|
+
assert_types(dtype_of(array), DType)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The checker still sees the exact type; the value is checked against the stand-in.
|
|
180
|
+
|
|
125
181
|
## Choosing a checker
|
|
126
182
|
|
|
127
183
|
```toml
|
|
@@ -163,6 +219,18 @@ after the file's setup has run, so making an entry conditional is ordinary Pytho
|
|
|
163
219
|
entry naming an expression that no case makes fails the file's `setup` test, so a skip
|
|
164
220
|
cannot quietly outlive the case it was written for.
|
|
165
221
|
|
|
222
|
+
## Running the cases on their own
|
|
223
|
+
|
|
224
|
+
Case files collect like any other test file, so a job can run just them:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
pytest tests/typing/cases --no-cov
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
pytest-cov's `--no-cov` matters when the project sets a coverage threshold in
|
|
231
|
+
`addopts`: the cases exercise only what they call, so `--cov-fail-under` would fail a
|
|
232
|
+
run that is only about types. Runs of the whole suite are unaffected.
|
|
233
|
+
|
|
166
234
|
## License
|
|
167
235
|
|
|
168
236
|
MIT
|
|
@@ -1,55 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|