type-assert 0.2.1__tar.gz → 0.2.2__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.2.1 → type_assert-0.2.2}/PKG-INFO +1 -1
- {type_assert-0.2.1 → type_assert-0.2.2}/pyproject.toml +1 -1
- {type_assert-0.2.1 → type_assert-0.2.2}/tests/test_plugin.py +9 -0
- type_assert-0.2.2/tests/test_share.py +151 -0
- type_assert-0.2.2/type_assert/_share.py +105 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert/_version.py +3 -3
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert/plugin.py +12 -9
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert.egg-info/PKG-INFO +1 -1
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert.egg-info/SOURCES.txt +2 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert.egg-info/scm_file_list.json +2 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert.egg-info/scm_version.json +2 -2
- {type_assert-0.2.1 → type_assert-0.2.2}/.github/dependabot.yml +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/.github/release.yml +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/.github/workflows/ci.yml +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/.gitignore +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/.pre-commit-config.yaml +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/LICENSE +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/README.md +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/setup.cfg +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/tests/conftest.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/tests/test_assertions.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/tests/test_cases.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/tests/test_checkers.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/tools/check_without_checker.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert/__init__.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert/_assertions.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert/_cases.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert/_checkers/__init__.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert/_checkers/_base.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert/_checkers/_mypy.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert/_checkers/_pyrefly.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert/_checkers/_pyright.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert/_exact.py +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert/py.typed +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert.egg-info/dependency_links.txt +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert.egg-info/entry_points.txt +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert.egg-info/requires.txt +0 -0
- {type_assert-0.2.1 → type_assert-0.2.2}/type_assert.egg-info/top_level.txt +0 -0
|
@@ -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', 'numpy', 'pyrefly', 'pyright', 'pytest']
|
|
7
|
+
test = ['mypy', 'numpy', 'pyrefly', 'pyright', 'pytest', 'pytest-xdist']
|
|
8
8
|
|
|
9
9
|
[project]
|
|
10
10
|
authors = [{ name = 'user27182' }]
|
|
@@ -365,3 +365,12 @@ class TestCheckerConfiguration:
|
|
|
365
365
|
directory.joinpath('sample.py').write_text(CLEAN, encoding='utf-8')
|
|
366
366
|
result = pytester.runpytest()
|
|
367
367
|
result.stdout.fnmatch_lines(['*mypy failed to run*'])
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_the_checker_runs_once_across_xdist_workers(project):
|
|
371
|
+
# Each worker would otherwise type-check the cases again, into a cache of its own.
|
|
372
|
+
pytester = project(CLEAN)
|
|
373
|
+
result = pytester.runpytest_subprocess('-p', 'xdist', '-n', '2')
|
|
374
|
+
result.assert_outcomes(passed=5)
|
|
375
|
+
caches = list((pytester.path / '.mypy_cache').glob('type_assert-*'))
|
|
376
|
+
assert [path.name for path in caches] == ['type_assert-mypy']
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Sharing one checker run between pytest-xdist workers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from type_assert import CheckerError
|
|
11
|
+
from type_assert import Diagnostic
|
|
12
|
+
from type_assert._share import _decode
|
|
13
|
+
from type_assert._share import _encode
|
|
14
|
+
from type_assert._share import run_once
|
|
15
|
+
from type_assert._share import shared_dir
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FakeConfig:
|
|
19
|
+
"""Stands in for the pytest config the sharing helper reads."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, basetemp=None, worker=None):
|
|
22
|
+
"""Record what the helper looks for."""
|
|
23
|
+
if worker is not None:
|
|
24
|
+
self.workerinput = {'workerid': worker}
|
|
25
|
+
if basetemp is not None:
|
|
26
|
+
self._tmp_path_factory = FakeFactory(basetemp)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FakeFactory:
|
|
30
|
+
"""Stands in for pytest's tmp path factory."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, basetemp):
|
|
33
|
+
"""Record the base directory."""
|
|
34
|
+
self._basetemp = basetemp
|
|
35
|
+
|
|
36
|
+
def getbasetemp(self):
|
|
37
|
+
"""Return the worker's own temporary directory."""
|
|
38
|
+
return self._basetemp
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def worker(tmp_path):
|
|
43
|
+
"""Return a config that looks like an xdist worker."""
|
|
44
|
+
|
|
45
|
+
def _worker(name='gw0'):
|
|
46
|
+
basetemp = tmp_path / f'popen-{name}'
|
|
47
|
+
basetemp.mkdir(exist_ok=True)
|
|
48
|
+
return FakeConfig(basetemp=basetemp, worker=name)
|
|
49
|
+
|
|
50
|
+
return _worker
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DIAGNOSTICS = {Path('/a/b.py'): [Diagnostic(path=Path('/a/b.py'), line=3, message='boom')]}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_round_trips_diagnostics():
|
|
57
|
+
assert _decode(json.dumps({'diagnostics': json.loads(_encode(DIAGNOSTICS))})) == DIAGNOSTICS
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_no_shared_directory_without_xdist(tmp_path):
|
|
61
|
+
assert shared_dir(FakeConfig(basetemp=tmp_path)) is None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_workers_agree_on_one_directory(worker):
|
|
65
|
+
assert shared_dir(worker('gw0')) == shared_dir(worker('gw1'))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_runs_directly_when_not_under_xdist(tmp_path):
|
|
69
|
+
calls = []
|
|
70
|
+
|
|
71
|
+
def run():
|
|
72
|
+
calls.append(1)
|
|
73
|
+
return DIAGNOSTICS
|
|
74
|
+
|
|
75
|
+
assert run_once(FakeConfig(basetemp=tmp_path), 'mypy', run) == DIAGNOSTICS
|
|
76
|
+
assert run_once(FakeConfig(basetemp=tmp_path), 'mypy', run) == DIAGNOSTICS
|
|
77
|
+
# Nothing to share without workers, so it runs every time.
|
|
78
|
+
assert len(calls) == 2
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_only_the_first_worker_runs_the_checker(worker):
|
|
82
|
+
calls = []
|
|
83
|
+
|
|
84
|
+
def run():
|
|
85
|
+
calls.append(1)
|
|
86
|
+
return DIAGNOSTICS
|
|
87
|
+
|
|
88
|
+
first = run_once(worker('gw0'), 'mypy', run)
|
|
89
|
+
second = run_once(worker('gw1'), 'mypy', run)
|
|
90
|
+
third = run_once(worker('gw2'), 'mypy', run)
|
|
91
|
+
|
|
92
|
+
assert first == second == third == DIAGNOSTICS
|
|
93
|
+
assert len(calls) == 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_each_checker_runs_once(worker):
|
|
97
|
+
calls = []
|
|
98
|
+
|
|
99
|
+
def run():
|
|
100
|
+
calls.append(1)
|
|
101
|
+
return DIAGNOSTICS
|
|
102
|
+
|
|
103
|
+
run_once(worker('gw0'), 'mypy', run)
|
|
104
|
+
run_once(worker('gw1'), 'pyright', run)
|
|
105
|
+
run_once(worker('gw2'), 'mypy', run)
|
|
106
|
+
assert len(calls) == 2
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_a_checker_failure_reaches_the_other_workers(worker):
|
|
110
|
+
def run():
|
|
111
|
+
msg = 'mypy failed to run'
|
|
112
|
+
raise CheckerError(msg)
|
|
113
|
+
|
|
114
|
+
with pytest.raises(CheckerError, match='mypy failed to run'):
|
|
115
|
+
run_once(worker('gw0'), 'mypy', run)
|
|
116
|
+
|
|
117
|
+
def unreachable(): # pragma: no cover - the stored failure is raised first
|
|
118
|
+
raise AssertionError
|
|
119
|
+
|
|
120
|
+
with pytest.raises(CheckerError, match='mypy failed to run'):
|
|
121
|
+
run_once(worker('gw1'), 'mypy', unreachable)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_an_unexpected_failure_also_reaches_the_other_workers(worker):
|
|
125
|
+
def run():
|
|
126
|
+
msg = 'something else'
|
|
127
|
+
raise RuntimeError(msg)
|
|
128
|
+
|
|
129
|
+
with pytest.raises(RuntimeError, match='something else'):
|
|
130
|
+
run_once(worker('gw0'), 'mypy', run)
|
|
131
|
+
|
|
132
|
+
def unreachable(): # pragma: no cover - the stored failure is raised first
|
|
133
|
+
raise AssertionError
|
|
134
|
+
|
|
135
|
+
with pytest.raises(CheckerError, match='RuntimeError: something else'):
|
|
136
|
+
run_once(worker('gw1'), 'mypy', unreachable)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_waiting_gives_up_rather_than_hanging(worker, monkeypatch):
|
|
140
|
+
monkeypatch.setattr('type_assert._share.TIMEOUT', 0.0)
|
|
141
|
+
config = worker('gw0')
|
|
142
|
+
# A claim with no result is what a worker killed mid-run leaves behind.
|
|
143
|
+
directory = shared_dir(config)
|
|
144
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
(directory / 'mypy.claim').mkdir()
|
|
146
|
+
|
|
147
|
+
def unreachable(): # pragma: no cover - the wait gives up first
|
|
148
|
+
raise AssertionError
|
|
149
|
+
|
|
150
|
+
with pytest.raises(CheckerError, match='Timed out'):
|
|
151
|
+
run_once(config, 'mypy', unreachable)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Run a checker once per session when pytest-xdist spreads the cases over workers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import time
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from ._checkers import CheckerError
|
|
11
|
+
from ._checkers import Diagnostic
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
#: How long a worker waits for whichever worker is running the checker.
|
|
19
|
+
TIMEOUT = 600.0
|
|
20
|
+
POLL = 0.1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def shared_dir(config: pytest.Config) -> Path | None:
|
|
24
|
+
"""Return the directory every xdist worker shares, or `None` when not under xdist."""
|
|
25
|
+
if not hasattr(config, 'workerinput'):
|
|
26
|
+
return None
|
|
27
|
+
factory = getattr(config, '_tmp_path_factory', None)
|
|
28
|
+
if factory is None: # pragma: no cover - pytest always provides it
|
|
29
|
+
return None
|
|
30
|
+
return Path(factory.getbasetemp()).parent / 'type_assert'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _encode(diagnostics: dict[Path, list[Diagnostic]]) -> str:
|
|
34
|
+
"""Return the diagnostics as JSON."""
|
|
35
|
+
return json.dumps(
|
|
36
|
+
{
|
|
37
|
+
str(path): [
|
|
38
|
+
{'path': str(item.path), 'line': item.line, 'message': item.message}
|
|
39
|
+
for item in items
|
|
40
|
+
]
|
|
41
|
+
for path, items in diagnostics.items()
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _decode(payload: str) -> dict[Path, list[Diagnostic]]:
|
|
47
|
+
"""Return the diagnostics read back from JSON."""
|
|
48
|
+
stored = json.loads(payload)
|
|
49
|
+
if 'error' in stored:
|
|
50
|
+
raise CheckerError(stored['error'])
|
|
51
|
+
return {
|
|
52
|
+
Path(path): [
|
|
53
|
+
Diagnostic(path=Path(item['path']), line=item['line'], message=item['message'])
|
|
54
|
+
for item in items
|
|
55
|
+
]
|
|
56
|
+
for path, items in stored['diagnostics'].items()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_once(
|
|
61
|
+
config: pytest.Config, name: str, run: Callable[[], dict[Path, list[Diagnostic]]]
|
|
62
|
+
) -> dict[Path, list[Diagnostic]]:
|
|
63
|
+
"""Return `run()`, calling it in only one xdist worker and sharing the result.
|
|
64
|
+
|
|
65
|
+
Without xdist, or if the workers cannot agree on a directory, `run` is called
|
|
66
|
+
directly.
|
|
67
|
+
"""
|
|
68
|
+
directory = shared_dir(config)
|
|
69
|
+
if directory is None:
|
|
70
|
+
return run()
|
|
71
|
+
|
|
72
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
result = directory / f'{name}.json'
|
|
74
|
+
claim = directory / f'{name}.claim'
|
|
75
|
+
deadline = time.monotonic() + TIMEOUT
|
|
76
|
+
|
|
77
|
+
while True:
|
|
78
|
+
if result.is_file():
|
|
79
|
+
return _decode(result.read_text(encoding='utf-8'))
|
|
80
|
+
try:
|
|
81
|
+
claim.mkdir()
|
|
82
|
+
except FileExistsError:
|
|
83
|
+
if time.monotonic() > deadline:
|
|
84
|
+
msg = f'Timed out waiting {TIMEOUT:.0f}s for another worker to run {name}.'
|
|
85
|
+
raise CheckerError(msg) from None
|
|
86
|
+
time.sleep(POLL)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
diagnostics = run()
|
|
91
|
+
except CheckerError as error:
|
|
92
|
+
_publish(result, json.dumps({'error': str(error)}))
|
|
93
|
+
raise
|
|
94
|
+
except BaseException as error:
|
|
95
|
+
_publish(result, json.dumps({'error': f'{type(error).__name__}: {error}'}))
|
|
96
|
+
raise
|
|
97
|
+
_publish(result, json.dumps({'diagnostics': json.loads(_encode(diagnostics))}))
|
|
98
|
+
return diagnostics
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _publish(result: Path, payload: str) -> None:
|
|
102
|
+
"""Write `payload` where the other workers will find it, in one step."""
|
|
103
|
+
pending = result.with_suffix('.pending')
|
|
104
|
+
pending.write_text(payload, encoding='utf-8')
|
|
105
|
+
pending.replace(result)
|
|
@@ -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.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
21
|
+
__version__ = version = '0.2.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 2)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g1137e2bda'
|
|
@@ -23,6 +23,7 @@ from ._cases import CaseSkipped
|
|
|
23
23
|
from ._cases import collect_case_file
|
|
24
24
|
from ._checkers import CHECKERS
|
|
25
25
|
from ._checkers import get_checker
|
|
26
|
+
from ._share import run_once
|
|
26
27
|
|
|
27
28
|
if TYPE_CHECKING:
|
|
28
29
|
from ._cases import Case
|
|
@@ -124,15 +125,17 @@ def _diagnostics(config: pytest.Config, checker_name: str) -> dict[Path, list[Di
|
|
|
124
125
|
assert directory is not None # only reachable from a collected case file
|
|
125
126
|
root = Path(config.rootpath)
|
|
126
127
|
checker = get_checker(checker_name)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
128
|
+
|
|
129
|
+
def run() -> dict[Path, list[Diagnostic]]:
|
|
130
|
+
"""Type-check the cases with this checker."""
|
|
131
|
+
return checker.run(
|
|
132
|
+
'.'.join(directory.relative_to(root).parts),
|
|
133
|
+
root=root,
|
|
134
|
+
cache_dir=root / '.mypy_cache' / f'type_assert-{checker_name}',
|
|
135
|
+
extra_args=[str(arg) for arg in config.getini(checker_args_ini(checker_name))],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
cache[checker_name] = run_once(config, checker_name, run)
|
|
136
139
|
return cache[checker_name]
|
|
137
140
|
|
|
138
141
|
|
|
@@ -11,11 +11,13 @@ tests/test_assertions.py
|
|
|
11
11
|
tests/test_cases.py
|
|
12
12
|
tests/test_checkers.py
|
|
13
13
|
tests/test_plugin.py
|
|
14
|
+
tests/test_share.py
|
|
14
15
|
tools/check_without_checker.py
|
|
15
16
|
type_assert/__init__.py
|
|
16
17
|
type_assert/_assertions.py
|
|
17
18
|
type_assert/_cases.py
|
|
18
19
|
type_assert/_exact.py
|
|
20
|
+
type_assert/_share.py
|
|
19
21
|
type_assert/_version.py
|
|
20
22
|
type_assert/plugin.py
|
|
21
23
|
type_assert/py.typed
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"tests/test_cases.py",
|
|
14
14
|
"tests/test_checkers.py",
|
|
15
15
|
"tests/test_plugin.py",
|
|
16
|
+
"tests/test_share.py",
|
|
16
17
|
"tools/check_without_checker.py",
|
|
17
18
|
"type_assert/__init__.py",
|
|
18
19
|
"type_assert/_assertions.py",
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
"type_assert/_checkers/_pyrefly.py",
|
|
24
25
|
"type_assert/_checkers/_pyright.py",
|
|
25
26
|
"type_assert/_exact.py",
|
|
27
|
+
"type_assert/_share.py",
|
|
26
28
|
"type_assert/plugin.py",
|
|
27
29
|
"type_assert/py.typed"
|
|
28
30
|
]
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|