type-assert 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- type_assert-0.1.0/.github/dependabot.yml +16 -0
- type_assert-0.1.0/.github/release.yml +9 -0
- type_assert-0.1.0/.github/workflows/ci.yml +112 -0
- type_assert-0.1.0/.gitignore +14 -0
- type_assert-0.1.0/.pre-commit-config.yaml +38 -0
- type_assert-0.1.0/LICENSE +21 -0
- type_assert-0.1.0/PKG-INFO +168 -0
- type_assert-0.1.0/README.md +128 -0
- type_assert-0.1.0/pyproject.toml +125 -0
- type_assert-0.1.0/setup.cfg +4 -0
- type_assert-0.1.0/tests/conftest.py +37 -0
- type_assert-0.1.0/tests/test_assertions.py +167 -0
- type_assert-0.1.0/tests/test_cases.py +217 -0
- type_assert-0.1.0/tests/test_checkers.py +284 -0
- type_assert-0.1.0/tests/test_plugin.py +310 -0
- type_assert-0.1.0/tools/check_without_checker.py +36 -0
- type_assert-0.1.0/type_assert/__init__.py +27 -0
- type_assert-0.1.0/type_assert/_assertions.py +55 -0
- type_assert-0.1.0/type_assert/_cases.py +182 -0
- type_assert-0.1.0/type_assert/_checkers/__init__.py +43 -0
- type_assert-0.1.0/type_assert/_checkers/_base.py +54 -0
- type_assert-0.1.0/type_assert/_checkers/_mypy.py +86 -0
- type_assert-0.1.0/type_assert/_checkers/_pyrefly.py +100 -0
- type_assert-0.1.0/type_assert/_checkers/_pyright.py +97 -0
- type_assert-0.1.0/type_assert/_version.py +24 -0
- type_assert-0.1.0/type_assert/plugin.py +227 -0
- type_assert-0.1.0/type_assert/py.typed +0 -0
- type_assert-0.1.0/type_assert.egg-info/PKG-INFO +168 -0
- type_assert-0.1.0/type_assert.egg-info/SOURCES.txt +33 -0
- type_assert-0.1.0/type_assert.egg-info/dependency_links.txt +1 -0
- type_assert-0.1.0/type_assert.egg-info/entry_points.txt +2 -0
- type_assert-0.1.0/type_assert.egg-info/requires.txt +15 -0
- type_assert-0.1.0/type_assert.egg-info/scm_file_list.json +28 -0
- type_assert-0.1.0/type_assert.egg-info/scm_version.json +8 -0
- type_assert-0.1.0/type_assert.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: pip
|
|
4
|
+
directory: /
|
|
5
|
+
schedule:
|
|
6
|
+
interval: weekly
|
|
7
|
+
labels: [maintenance, dependencies]
|
|
8
|
+
open-pull-requests-limit: 20
|
|
9
|
+
- package-ecosystem: github-actions
|
|
10
|
+
directory: /.github/workflows
|
|
11
|
+
schedule:
|
|
12
|
+
interval: weekly
|
|
13
|
+
labels: [maintenance, dependencies]
|
|
14
|
+
groups:
|
|
15
|
+
artifacts:
|
|
16
|
+
patterns: [actions/upload-artifact, actions/download-artifact]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
# `pull_request` is deliberately unfiltered: filtering it by base branch would
|
|
4
|
+
# skip every pull request in a stack that does not target main.
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
tags: [v*]
|
|
9
|
+
pull_request:
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: true
|
|
14
|
+
|
|
15
|
+
permissions:
|
|
16
|
+
contents: read
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
pre-commit:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
23
|
+
with:
|
|
24
|
+
persist-credentials: false
|
|
25
|
+
fetch-depth: 0
|
|
26
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
27
|
+
with:
|
|
28
|
+
enable-cache: false
|
|
29
|
+
- run: uv sync --group dev
|
|
30
|
+
- run: uv run pre-commit run --all-files --show-diff-on-failure
|
|
31
|
+
env:
|
|
32
|
+
# This hook exists to stop *local* commits straight to main; it
|
|
33
|
+
# would always fail here since CI checks out that branch/tag directly.
|
|
34
|
+
SKIP: no-commit-to-branch
|
|
35
|
+
|
|
36
|
+
test:
|
|
37
|
+
# Every supported Python, on every platform: the plugin shells out to a
|
|
38
|
+
# checker and parses its output, which is exactly where paths differ.
|
|
39
|
+
runs-on: ${{ matrix.os }}
|
|
40
|
+
strategy:
|
|
41
|
+
fail-fast: false
|
|
42
|
+
matrix:
|
|
43
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
44
|
+
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
|
45
|
+
steps:
|
|
46
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
47
|
+
with:
|
|
48
|
+
persist-credentials: false
|
|
49
|
+
fetch-depth: 0
|
|
50
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
51
|
+
with:
|
|
52
|
+
enable-cache: false
|
|
53
|
+
- run: uv sync --group dev --extra all --python ${{ matrix.python-version }}
|
|
54
|
+
# Coverage has to start before pytest, since the plugin is imported by
|
|
55
|
+
# entry point before any pytest-cov hook would run.
|
|
56
|
+
- run: uv run coverage run -m pytest
|
|
57
|
+
- run: uv run coverage combine
|
|
58
|
+
- run: uv run coverage report --fail-under=95
|
|
59
|
+
- run: uv run coverage xml
|
|
60
|
+
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
|
61
|
+
if: matrix.os == 'ubuntu-latest'
|
|
62
|
+
with:
|
|
63
|
+
files: ./coverage.xml
|
|
64
|
+
env:
|
|
65
|
+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
66
|
+
|
|
67
|
+
test-no-checker:
|
|
68
|
+
# Neither checker is a hard dependency. With neither installed the package
|
|
69
|
+
# must still import and the runtime half must still work; only the static
|
|
70
|
+
# half is unavailable, and it has to say so plainly.
|
|
71
|
+
runs-on: ubuntu-latest
|
|
72
|
+
steps:
|
|
73
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
74
|
+
with:
|
|
75
|
+
persist-credentials: false
|
|
76
|
+
fetch-depth: 0
|
|
77
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
78
|
+
with:
|
|
79
|
+
enable-cache: false
|
|
80
|
+
- run: uv sync --no-dev
|
|
81
|
+
- run: uv run --no-sync python tools/check_without_checker.py
|
|
82
|
+
|
|
83
|
+
build:
|
|
84
|
+
needs: [pre-commit, test]
|
|
85
|
+
runs-on: ubuntu-latest
|
|
86
|
+
steps:
|
|
87
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
88
|
+
with:
|
|
89
|
+
persist-credentials: false
|
|
90
|
+
fetch-depth: 0
|
|
91
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
92
|
+
with:
|
|
93
|
+
enable-cache: false
|
|
94
|
+
- run: uv build
|
|
95
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
96
|
+
with:
|
|
97
|
+
name: dist
|
|
98
|
+
path: dist/
|
|
99
|
+
|
|
100
|
+
publish:
|
|
101
|
+
needs: build
|
|
102
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
103
|
+
runs-on: ubuntu-latest
|
|
104
|
+
environment: release
|
|
105
|
+
permissions:
|
|
106
|
+
id-token: write
|
|
107
|
+
steps:
|
|
108
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
109
|
+
with:
|
|
110
|
+
name: dist
|
|
111
|
+
path: dist/
|
|
112
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
ci:
|
|
2
|
+
autoupdate_commit_msg: 'chore: update pre-commit hooks'
|
|
3
|
+
autofix_prs: true
|
|
4
|
+
autoupdate_schedule: quarterly
|
|
5
|
+
|
|
6
|
+
repos:
|
|
7
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
8
|
+
rev: v5.0.0
|
|
9
|
+
hooks:
|
|
10
|
+
- id: check-merge-conflict
|
|
11
|
+
- id: debug-statements
|
|
12
|
+
- id: no-commit-to-branch
|
|
13
|
+
args: [--branch, main]
|
|
14
|
+
|
|
15
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
16
|
+
rev: v0.16.0
|
|
17
|
+
hooks:
|
|
18
|
+
- id: ruff-check
|
|
19
|
+
args: [--fix, --show-fixes]
|
|
20
|
+
- id: ruff-format
|
|
21
|
+
|
|
22
|
+
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
|
23
|
+
rev: v1.11.0
|
|
24
|
+
hooks:
|
|
25
|
+
- id: zizmor
|
|
26
|
+
|
|
27
|
+
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
|
|
28
|
+
rev: v2.15.0
|
|
29
|
+
hooks:
|
|
30
|
+
- id: pretty-format-yaml
|
|
31
|
+
args: [--autofix, --indent, '2']
|
|
32
|
+
|
|
33
|
+
- repo: https://github.com/ComPWA/taplo-pre-commit
|
|
34
|
+
rev: v0.9.3
|
|
35
|
+
hooks:
|
|
36
|
+
- id: taplo-format
|
|
37
|
+
# See options: https://taplo.tamasfe.dev/configuration/formatter-options.html
|
|
38
|
+
args: [--option, reorder_arrays=true, --option, reorder_keys=true, --option, align_comments=false]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 user27182
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: type-assert
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: pytest plugin that checks a value static type and its runtime type in one assertion
|
|
5
|
+
Author: user27182
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/user27182/type-assert
|
|
8
|
+
Project-URL: Issues, https://github.com/user27182/type-assert/issues
|
|
9
|
+
Keywords: annotations,mypy,pyright,pytest,typing
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
+
Classifier: Topic :: Software Development :: Testing
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: pycroscope<0.6,>=0.5
|
|
29
|
+
Requires-Dist: pytest>=7
|
|
30
|
+
Requires-Dist: typing-extensions>=4.5
|
|
31
|
+
Provides-Extra: all
|
|
32
|
+
Requires-Dist: type-assert[mypy,pyrefly,pyright]; extra == "all"
|
|
33
|
+
Provides-Extra: mypy
|
|
34
|
+
Requires-Dist: mypy>=1.11; extra == "mypy"
|
|
35
|
+
Provides-Extra: pyrefly
|
|
36
|
+
Requires-Dist: pyrefly>=1.0; extra == "pyrefly"
|
|
37
|
+
Provides-Extra: pyright
|
|
38
|
+
Requires-Dist: pyright>=1.1.390; extra == "pyright"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# type-assert
|
|
42
|
+
|
|
43
|
+
pytest plugin that checks a value's static type and its runtime type in one assertion.
|
|
44
|
+
|
|
45
|
+
A type checker only ever sees the annotations. A runtime checker only ever sees the
|
|
46
|
+
values. Either can be right while the other is wrong, and overloaded signatures are
|
|
47
|
+
where they drift apart. `type-assert` pins both halves at once, from one line:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
assert_types(json.loads('[1]'), Any)
|
|
51
|
+
assert_types(sorted({'b', 'a'}), list[str])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Each line becomes two tests. One runs the expression and checks the value it produced.
|
|
55
|
+
The other checks what a type checker inferred for the same line. The line only passes
|
|
56
|
+
if the two agree.
|
|
57
|
+
|
|
58
|
+
> **Warning** — The API of this package is unstable and likely to change between
|
|
59
|
+
> minor versions (for example `0.1.0` to `0.2.0`). Pin the exact version you
|
|
60
|
+
> depend on, for example `type-assert==0.1.0`.
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install type-assert[mypy] # or [pyright], [pyrefly], or [all]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The checker itself is an extra, because it should be whichever one your project
|
|
69
|
+
already uses.
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
Put a directory of case files somewhere in your test tree and point the plugin at it:
|
|
74
|
+
|
|
75
|
+
```toml
|
|
76
|
+
[tool.pytest.ini_options]
|
|
77
|
+
type_assert_cases = 'tests/typing/cases'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A case file is an ordinary Python module. Every top-level `assert_types` call is a case;
|
|
81
|
+
everything else — imports, helpers, constants — is setup shared by the cases in that
|
|
82
|
+
file:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from __future__ import annotations
|
|
86
|
+
|
|
87
|
+
import json
|
|
88
|
+
from typing import Any
|
|
89
|
+
|
|
90
|
+
from type_assert import assert_types
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def payload() -> str:
|
|
94
|
+
"""Return a document to parse."""
|
|
95
|
+
return '{"a": 1}'
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
assert_types(json.loads(payload()), Any)
|
|
99
|
+
assert_types(sorted({'b', 'a'}), list[str])
|
|
100
|
+
assert_types(''.join([]), str)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Running pytest collects each case file as a test file of its own:
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
tests/typing/cases/basics.py::setup
|
|
107
|
+
tests/typing/cases/basics.py::sorted({'b', 'a'}) -> list[str] [runtime]
|
|
108
|
+
tests/typing/cases/basics.py::sorted({'b', 'a'}) -> list[str] [static]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## How `assert_types` does both
|
|
112
|
+
|
|
113
|
+
To a type checker, `assert_types` *is*
|
|
114
|
+
[`typing_extensions.assert_type`](https://typing-extensions.readthedocs.io/en/latest/#typing_extensions.assert_type),
|
|
115
|
+
aliased under `TYPE_CHECKING`. Checkers resolve an aliased import back to its original
|
|
116
|
+
definition, so the special case still applies: the inferred type must match the second
|
|
117
|
+
argument **exactly**, and a supertype is a failure rather than a pass.
|
|
118
|
+
|
|
119
|
+
At runtime that name is bound to a real checker instead, backed by
|
|
120
|
+
[pycroscope](https://pycroscope.readthedocs.io/), which walks containers exhaustively —
|
|
121
|
+
it catches a `None` at any position in a `list[int]`, not only the first element.
|
|
122
|
+
|
|
123
|
+
Writing the type once covers both halves, and there is no way for them to drift apart.
|
|
124
|
+
|
|
125
|
+
## Choosing a checker
|
|
126
|
+
|
|
127
|
+
```toml
|
|
128
|
+
[tool.pytest.ini_options]
|
|
129
|
+
type_assert_checkers = 'mypy' # the default
|
|
130
|
+
type_assert_checkers = 'pyright'
|
|
131
|
+
type_assert_checkers = 'mypy pyright pyrefly' # each with its own test
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Naming more than one gives every case a static test per checker, so a case has to hold
|
|
135
|
+
under all of them:
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
cases/basics.py::sorted({'b', 'a'}) -> list[str] [runtime]
|
|
139
|
+
cases/basics.py::sorted({'b', 'a'}) -> list[str] [static: mypy]
|
|
140
|
+
cases/basics.py::sorted({'b', 'a'}) -> list[str] [static: pyright]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The runtime test is not repeated, since the value does not depend on who checked it.
|
|
144
|
+
Bear in mind that two checkers do not always infer the same type for the same
|
|
145
|
+
expression, so a case that satisfies one may need rewording to satisfy both.
|
|
146
|
+
|
|
147
|
+
`ty` is deliberately not supported yet: it is pre-1.0 and its output format is still
|
|
148
|
+
moving. Adding a backend is a single module — see `type_assert/_checkers/`.
|
|
149
|
+
|
|
150
|
+
## Skipping a case at runtime
|
|
151
|
+
|
|
152
|
+
A case that cannot run everywhere — it crashes on a platform, or needs something that is
|
|
153
|
+
not always installed — is named in a `SKIP_RUNTIME` mapping in its own file:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
SKIP_RUNTIME = {
|
|
157
|
+
'expression exactly as written': 'why running it fails here',
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Only the runtime half is skipped; the checker still checks the case. The mapping is read
|
|
162
|
+
after the file's setup has run, so making an entry conditional is ordinary Python. An
|
|
163
|
+
entry naming an expression that no case makes fails the file's `setup` test, so a skip
|
|
164
|
+
cannot quietly outlive the case it was written for.
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# type-assert
|
|
2
|
+
|
|
3
|
+
pytest plugin that checks a value's static type and its runtime type in one assertion.
|
|
4
|
+
|
|
5
|
+
A type checker only ever sees the annotations. A runtime checker only ever sees the
|
|
6
|
+
values. Either can be right while the other is wrong, and overloaded signatures are
|
|
7
|
+
where they drift apart. `type-assert` pins both halves at once, from one line:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
assert_types(json.loads('[1]'), Any)
|
|
11
|
+
assert_types(sorted({'b', 'a'}), list[str])
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Each line becomes two tests. One runs the expression and checks the value it produced.
|
|
15
|
+
The other checks what a type checker inferred for the same line. The line only passes
|
|
16
|
+
if the two agree.
|
|
17
|
+
|
|
18
|
+
> **Warning** — The API of this package is unstable and likely to change between
|
|
19
|
+
> minor versions (for example `0.1.0` to `0.2.0`). Pin the exact version you
|
|
20
|
+
> depend on, for example `type-assert==0.1.0`.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install type-assert[mypy] # or [pyright], [pyrefly], or [all]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The checker itself is an extra, because it should be whichever one your project
|
|
29
|
+
already uses.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Put a directory of case files somewhere in your test tree and point the plugin at it:
|
|
34
|
+
|
|
35
|
+
```toml
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
type_assert_cases = 'tests/typing/cases'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A case file is an ordinary Python module. Every top-level `assert_types` call is a case;
|
|
41
|
+
everything else — imports, helpers, constants — is setup shared by the cases in that
|
|
42
|
+
file:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
from typing import Any
|
|
49
|
+
|
|
50
|
+
from type_assert import assert_types
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def payload() -> str:
|
|
54
|
+
"""Return a document to parse."""
|
|
55
|
+
return '{"a": 1}'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
assert_types(json.loads(payload()), Any)
|
|
59
|
+
assert_types(sorted({'b', 'a'}), list[str])
|
|
60
|
+
assert_types(''.join([]), str)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Running pytest collects each case file as a test file of its own:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
tests/typing/cases/basics.py::setup
|
|
67
|
+
tests/typing/cases/basics.py::sorted({'b', 'a'}) -> list[str] [runtime]
|
|
68
|
+
tests/typing/cases/basics.py::sorted({'b', 'a'}) -> list[str] [static]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## How `assert_types` does both
|
|
72
|
+
|
|
73
|
+
To a type checker, `assert_types` *is*
|
|
74
|
+
[`typing_extensions.assert_type`](https://typing-extensions.readthedocs.io/en/latest/#typing_extensions.assert_type),
|
|
75
|
+
aliased under `TYPE_CHECKING`. Checkers resolve an aliased import back to its original
|
|
76
|
+
definition, so the special case still applies: the inferred type must match the second
|
|
77
|
+
argument **exactly**, and a supertype is a failure rather than a pass.
|
|
78
|
+
|
|
79
|
+
At runtime that name is bound to a real checker instead, backed by
|
|
80
|
+
[pycroscope](https://pycroscope.readthedocs.io/), which walks containers exhaustively —
|
|
81
|
+
it catches a `None` at any position in a `list[int]`, not only the first element.
|
|
82
|
+
|
|
83
|
+
Writing the type once covers both halves, and there is no way for them to drift apart.
|
|
84
|
+
|
|
85
|
+
## Choosing a checker
|
|
86
|
+
|
|
87
|
+
```toml
|
|
88
|
+
[tool.pytest.ini_options]
|
|
89
|
+
type_assert_checkers = 'mypy' # the default
|
|
90
|
+
type_assert_checkers = 'pyright'
|
|
91
|
+
type_assert_checkers = 'mypy pyright pyrefly' # each with its own test
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Naming more than one gives every case a static test per checker, so a case has to hold
|
|
95
|
+
under all of them:
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
cases/basics.py::sorted({'b', 'a'}) -> list[str] [runtime]
|
|
99
|
+
cases/basics.py::sorted({'b', 'a'}) -> list[str] [static: mypy]
|
|
100
|
+
cases/basics.py::sorted({'b', 'a'}) -> list[str] [static: pyright]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The runtime test is not repeated, since the value does not depend on who checked it.
|
|
104
|
+
Bear in mind that two checkers do not always infer the same type for the same
|
|
105
|
+
expression, so a case that satisfies one may need rewording to satisfy both.
|
|
106
|
+
|
|
107
|
+
`ty` is deliberately not supported yet: it is pre-1.0 and its output format is still
|
|
108
|
+
moving. Adding a backend is a single module — see `type_assert/_checkers/`.
|
|
109
|
+
|
|
110
|
+
## Skipping a case at runtime
|
|
111
|
+
|
|
112
|
+
A case that cannot run everywhere — it crashes on a platform, or needs something that is
|
|
113
|
+
not always installed — is named in a `SKIP_RUNTIME` mapping in its own file:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
SKIP_RUNTIME = {
|
|
117
|
+
'expression exactly as written': 'why running it fails here',
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Only the runtime half is skipped; the checker still checks the case. The mapping is read
|
|
122
|
+
after the file's setup has run, so making an entry conditional is ordinary Python. An
|
|
123
|
+
entry naming an expression that no case makes fails the file's `setup` test, so a skip
|
|
124
|
+
cannot quietly outlive the case it was written for.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = 'setuptools.build_meta'
|
|
3
|
+
requires = ['setuptools>=64', 'setuptools_scm>=8']
|
|
4
|
+
|
|
5
|
+
[dependency-groups]
|
|
6
|
+
dev = ['pre-commit', 'pytest-cov', { include-group = 'test' }]
|
|
7
|
+
test = ['mypy', 'pyrefly', 'pyright', 'pytest']
|
|
8
|
+
|
|
9
|
+
[project]
|
|
10
|
+
authors = [{ name = 'user27182' }]
|
|
11
|
+
classifiers = [
|
|
12
|
+
'Development Status :: 3 - Alpha',
|
|
13
|
+
'Framework :: Pytest',
|
|
14
|
+
'Intended Audience :: Developers',
|
|
15
|
+
'Operating System :: MacOS',
|
|
16
|
+
'Operating System :: Microsoft :: Windows',
|
|
17
|
+
'Operating System :: POSIX :: Linux',
|
|
18
|
+
'Programming Language :: Python :: 3',
|
|
19
|
+
'Programming Language :: Python :: 3.10',
|
|
20
|
+
'Programming Language :: Python :: 3.11',
|
|
21
|
+
'Programming Language :: Python :: 3.12',
|
|
22
|
+
'Programming Language :: Python :: 3.13',
|
|
23
|
+
'Programming Language :: Python :: 3.14',
|
|
24
|
+
'Topic :: Software Development :: Quality Assurance',
|
|
25
|
+
'Topic :: Software Development :: Testing',
|
|
26
|
+
'Typing :: Typed',
|
|
27
|
+
]
|
|
28
|
+
dependencies = ['pycroscope>=0.5,<0.6', 'pytest>=7', 'typing-extensions>=4.5']
|
|
29
|
+
description = 'pytest plugin that checks a value static type and its runtime type in one assertion'
|
|
30
|
+
dynamic = ['version']
|
|
31
|
+
keywords = ['annotations', 'mypy', 'pyright', 'pytest', 'typing']
|
|
32
|
+
license = 'MIT'
|
|
33
|
+
name = 'type-assert'
|
|
34
|
+
readme = 'README.md'
|
|
35
|
+
requires-python = '>=3.10'
|
|
36
|
+
|
|
37
|
+
[project.entry-points.pytest11]
|
|
38
|
+
type_assert = 'type_assert.plugin'
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
# A checker is whatever the project under test already uses; neither is required.
|
|
42
|
+
all = ['type-assert[mypy,pyrefly,pyright]']
|
|
43
|
+
mypy = ['mypy>=1.11']
|
|
44
|
+
pyrefly = ['pyrefly>=1.0']
|
|
45
|
+
pyright = ['pyright>=1.1.390']
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = 'https://github.com/user27182/type-assert'
|
|
49
|
+
Issues = 'https://github.com/user27182/type-assert/issues'
|
|
50
|
+
|
|
51
|
+
[tool.coverage.run]
|
|
52
|
+
branch = true
|
|
53
|
+
omit = ['*/_version.py']
|
|
54
|
+
parallel = true
|
|
55
|
+
source = ['type_assert']
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
addopts = '--strict-config --strict-markers'
|
|
59
|
+
testpaths = 'tests'
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
indent-width = 4
|
|
63
|
+
line-length = 99
|
|
64
|
+
|
|
65
|
+
[tool.ruff.format]
|
|
66
|
+
docstring-code-format = true
|
|
67
|
+
docstring-code-line-length = 75
|
|
68
|
+
quote-style = 'single'
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint]
|
|
71
|
+
extend-select = [
|
|
72
|
+
'A',
|
|
73
|
+
'B',
|
|
74
|
+
'C4',
|
|
75
|
+
'D',
|
|
76
|
+
'E',
|
|
77
|
+
'EM',
|
|
78
|
+
'F',
|
|
79
|
+
'FA',
|
|
80
|
+
'I',
|
|
81
|
+
'ICN',
|
|
82
|
+
'N',
|
|
83
|
+
'PERF',
|
|
84
|
+
'PGH',
|
|
85
|
+
'PIE',
|
|
86
|
+
'PT',
|
|
87
|
+
'RET',
|
|
88
|
+
'RSE',
|
|
89
|
+
'RUF',
|
|
90
|
+
'SIM',
|
|
91
|
+
'T10',
|
|
92
|
+
'T20',
|
|
93
|
+
'TCH',
|
|
94
|
+
'TID',
|
|
95
|
+
'UP',
|
|
96
|
+
'W',
|
|
97
|
+
'YTT',
|
|
98
|
+
]
|
|
99
|
+
ignore = ['D203', 'D211', 'D213']
|
|
100
|
+
|
|
101
|
+
[tool.ruff.lint.isort]
|
|
102
|
+
combine-as-imports = true
|
|
103
|
+
force-single-line = true
|
|
104
|
+
force-sort-within-sections = true
|
|
105
|
+
required-imports = ['from __future__ import annotations']
|
|
106
|
+
|
|
107
|
+
[tool.ruff.lint.per-file-ignores]
|
|
108
|
+
'tests/*' = ['D102', 'D103']
|
|
109
|
+
# `Union` and `Optional` here are runtime values handed to `assert_types`, not
|
|
110
|
+
# annotations. Both spellings are worth covering, since users write both.
|
|
111
|
+
'tests/test_assertions.py' = ['D102', 'D103', 'UP007', 'UP045']
|
|
112
|
+
|
|
113
|
+
[tool.ruff.lint.pyupgrade]
|
|
114
|
+
keep-runtime-typing = true
|
|
115
|
+
|
|
116
|
+
[tool.setuptools]
|
|
117
|
+
license-files = ['LICENSE']
|
|
118
|
+
packages = ['type_assert', 'type_assert._checkers']
|
|
119
|
+
|
|
120
|
+
[tool.setuptools.package-data]
|
|
121
|
+
type_assert = ['py.typed']
|
|
122
|
+
|
|
123
|
+
[tool.setuptools_scm]
|
|
124
|
+
version_scheme = 'release-branch-semver'
|
|
125
|
+
write_to = 'type_assert/_version.py'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Shared fixtures for the test suite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
pytest_plugins = ['pytester']
|
|
11
|
+
|
|
12
|
+
REPO_ROOT = Path(__file__).parent.parent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def checker_root(tmp_path, monkeypatch):
|
|
17
|
+
"""Return a project root the checkers can resolve `type_assert` from.
|
|
18
|
+
|
|
19
|
+
The project installs itself editable through an import finder, which neither
|
|
20
|
+
mypy nor pyright can follow, so point both at the source tree instead. This is
|
|
21
|
+
a fact about developing this package: an ordinary installation puts `type_assert`
|
|
22
|
+
in site-packages, where both find it with no help.
|
|
23
|
+
"""
|
|
24
|
+
monkeypatch.setenv('MYPYPATH', str(REPO_ROOT))
|
|
25
|
+
(tmp_path / 'pyrightconfig.json').write_text(
|
|
26
|
+
json.dumps({'extraPaths': [str(REPO_ROOT)]}), encoding='utf-8'
|
|
27
|
+
)
|
|
28
|
+
write_pyrefly_config(tmp_path)
|
|
29
|
+
return tmp_path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_pyrefly_config(directory):
|
|
33
|
+
"""Give `directory` a pyrefly config, which pyrefly refuses to work without."""
|
|
34
|
+
(directory / 'pyrefly.toml').write_text(
|
|
35
|
+
f'project-includes = ["cases"]\nsearch-path = [{str(REPO_ROOT)!r}]\n',
|
|
36
|
+
encoding='utf-8',
|
|
37
|
+
)
|