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.
Files changed (38) hide show
  1. {type_assert-0.1.0 → type_assert-0.2.1}/PKG-INFO +69 -1
  2. {type_assert-0.1.0 → type_assert-0.2.1}/README.md +68 -0
  3. {type_assert-0.1.0 → type_assert-0.2.1}/pyproject.toml +1 -1
  4. {type_assert-0.1.0 → type_assert-0.2.1}/tests/test_assertions.py +124 -0
  5. {type_assert-0.1.0 → type_assert-0.2.1}/tests/test_cases.py +46 -0
  6. {type_assert-0.1.0 → type_assert-0.2.1}/tests/test_plugin.py +57 -0
  7. type_assert-0.2.1/type_assert/_assertions.py +94 -0
  8. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_cases.py +43 -2
  9. type_assert-0.2.1/type_assert/_exact.py +165 -0
  10. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_version.py +3 -3
  11. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/plugin.py +25 -5
  12. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/PKG-INFO +69 -1
  13. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/SOURCES.txt +1 -0
  14. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/scm_file_list.json +1 -0
  15. type_assert-0.2.1/type_assert.egg-info/scm_version.json +8 -0
  16. type_assert-0.1.0/type_assert/_assertions.py +0 -55
  17. type_assert-0.1.0/type_assert.egg-info/scm_version.json +0 -8
  18. {type_assert-0.1.0 → type_assert-0.2.1}/.github/dependabot.yml +0 -0
  19. {type_assert-0.1.0 → type_assert-0.2.1}/.github/release.yml +0 -0
  20. {type_assert-0.1.0 → type_assert-0.2.1}/.github/workflows/ci.yml +0 -0
  21. {type_assert-0.1.0 → type_assert-0.2.1}/.gitignore +0 -0
  22. {type_assert-0.1.0 → type_assert-0.2.1}/.pre-commit-config.yaml +0 -0
  23. {type_assert-0.1.0 → type_assert-0.2.1}/LICENSE +0 -0
  24. {type_assert-0.1.0 → type_assert-0.2.1}/setup.cfg +0 -0
  25. {type_assert-0.1.0 → type_assert-0.2.1}/tests/conftest.py +0 -0
  26. {type_assert-0.1.0 → type_assert-0.2.1}/tests/test_checkers.py +0 -0
  27. {type_assert-0.1.0 → type_assert-0.2.1}/tools/check_without_checker.py +0 -0
  28. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/__init__.py +0 -0
  29. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_checkers/__init__.py +0 -0
  30. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_checkers/_base.py +0 -0
  31. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_checkers/_mypy.py +0 -0
  32. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_checkers/_pyrefly.py +0 -0
  33. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/_checkers/_pyright.py +0 -0
  34. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert/py.typed +0 -0
  35. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/dependency_links.txt +0 -0
  36. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/entry_points.txt +0 -0
  37. {type_assert-0.1.0 → type_assert-0.2.1}/type_assert.egg-info/requires.txt +0 -0
  38. {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.0
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=ast.unparse(call.args[1]),
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.0'
22
- __version_tuple__ = version_tuple = (0, 1, 0)
21
+ __version__ = version = '0.2.1'
22
+ __version_tuple__ = version_tuple = (0, 2, 1)
23
23
 
24
- __commit_id__ = commit_id = 'g868a2293d'
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
- '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)}.',
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
- directory = cases_dir(parent.config)
91
- if directory is None or file_path.suffix != '.py' or file_path.parent != directory:
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.0
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
@@ -15,6 +15,7 @@ tools/check_without_checker.py
15
15
  type_assert/__init__.py
16
16
  type_assert/_assertions.py
17
17
  type_assert/_cases.py
18
+ type_assert/_exact.py
18
19
  type_assert/_version.py
19
20
  type_assert/plugin.py
20
21
  type_assert/py.typed
@@ -22,6 +22,7 @@
22
22
  "type_assert/_checkers/_mypy.py",
23
23
  "type_assert/_checkers/_pyrefly.py",
24
24
  "type_assert/_checkers/_pyright.py",
25
+ "type_assert/_exact.py",
25
26
  "type_assert/plugin.py",
26
27
  "type_assert/py.typed"
27
28
  ]
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.2.1",
3
+ "distance": 0,
4
+ "node": "gc60ea7172b9c8d562dea6b21e1eb77e63352434f",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-05"
8
+ }
@@ -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
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.1.0",
3
- "distance": 0,
4
- "node": "g868a2293d84b44102b054ea5a2fb75032a3ae317",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-08-31"
8
- }
File without changes
File without changes
File without changes