type-assert 0.2.0__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.
Files changed (38) hide show
  1. {type_assert-0.2.0 → type_assert-0.2.2}/PKG-INFO +1 -1
  2. {type_assert-0.2.0 → type_assert-0.2.2}/pyproject.toml +1 -1
  3. {type_assert-0.2.0 → type_assert-0.2.2}/tests/test_plugin.py +23 -0
  4. type_assert-0.2.2/tests/test_share.py +151 -0
  5. type_assert-0.2.2/type_assert/_share.py +105 -0
  6. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert/_version.py +3 -3
  7. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert/plugin.py +34 -11
  8. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert.egg-info/PKG-INFO +1 -1
  9. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert.egg-info/SOURCES.txt +2 -0
  10. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert.egg-info/scm_file_list.json +2 -0
  11. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert.egg-info/scm_version.json +2 -2
  12. {type_assert-0.2.0 → type_assert-0.2.2}/.github/dependabot.yml +0 -0
  13. {type_assert-0.2.0 → type_assert-0.2.2}/.github/release.yml +0 -0
  14. {type_assert-0.2.0 → type_assert-0.2.2}/.github/workflows/ci.yml +0 -0
  15. {type_assert-0.2.0 → type_assert-0.2.2}/.gitignore +0 -0
  16. {type_assert-0.2.0 → type_assert-0.2.2}/.pre-commit-config.yaml +0 -0
  17. {type_assert-0.2.0 → type_assert-0.2.2}/LICENSE +0 -0
  18. {type_assert-0.2.0 → type_assert-0.2.2}/README.md +0 -0
  19. {type_assert-0.2.0 → type_assert-0.2.2}/setup.cfg +0 -0
  20. {type_assert-0.2.0 → type_assert-0.2.2}/tests/conftest.py +0 -0
  21. {type_assert-0.2.0 → type_assert-0.2.2}/tests/test_assertions.py +0 -0
  22. {type_assert-0.2.0 → type_assert-0.2.2}/tests/test_cases.py +0 -0
  23. {type_assert-0.2.0 → type_assert-0.2.2}/tests/test_checkers.py +0 -0
  24. {type_assert-0.2.0 → type_assert-0.2.2}/tools/check_without_checker.py +0 -0
  25. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert/__init__.py +0 -0
  26. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert/_assertions.py +0 -0
  27. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert/_cases.py +0 -0
  28. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert/_checkers/__init__.py +0 -0
  29. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert/_checkers/_base.py +0 -0
  30. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert/_checkers/_mypy.py +0 -0
  31. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert/_checkers/_pyrefly.py +0 -0
  32. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert/_checkers/_pyright.py +0 -0
  33. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert/_exact.py +0 -0
  34. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert/py.typed +0 -0
  35. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert.egg-info/dependency_links.txt +0 -0
  36. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert.egg-info/entry_points.txt +0 -0
  37. {type_assert-0.2.0 → type_assert-0.2.2}/type_assert.egg-info/requires.txt +0 -0
  38. {type_assert-0.2.0 → type_assert-0.2.2}/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.2.0
3
+ Version: 0.2.2
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
@@ -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' }]
@@ -164,6 +164,20 @@ def test_a_quoted_type_only_a_checker_can_build_is_checked_statically(project, c
164
164
  result.stdout.fnmatch_lines(['*cannot be built at runtime*'])
165
165
 
166
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
+
167
181
  def test_a_quoted_type_the_runtime_can_build_is_held_to_by_both_halves(project):
168
182
  source = (
169
183
  'from type_assert import assert_types\n\n'
@@ -351,3 +365,12 @@ class TestCheckerConfiguration:
351
365
  directory.joinpath('sample.py').write_text(CLEAN, encoding='utf-8')
352
366
  result = pytester.runpytest()
353
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.0'
22
- __version_tuple__ = version_tuple = (0, 2, 0)
21
+ __version__ = version = '0.2.2'
22
+ __version_tuple__ = version_tuple = (0, 2, 2)
23
23
 
24
- __commit_id__ = commit_id = 'gd0866227d'
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
@@ -85,14 +86,34 @@ def cases_dir(config: pytest.Config) -> Path | None:
85
86
  return (Path(config.rootpath) / configured).resolve()
86
87
 
87
88
 
89
+ def _is_case_file(file_path: Path, config: pytest.Config) -> bool:
90
+ """Tell whether `file_path` is a `.py` file in the configured cases directory."""
91
+ directory = cases_dir(config)
92
+ return directory is not None and file_path.suffix == '.py' and file_path.parent == directory
93
+
94
+
88
95
  def pytest_collect_file(file_path: Path, parent: pytest.Collector):
89
96
  """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:
97
+ # A path named on the command line reaches `pytest_pycollect_makemodule` below,
98
+ # which is where pytest's own Python collector would otherwise pick it up.
99
+ if not _is_case_file(file_path, parent.config) or parent.session.isinitpath(file_path):
92
100
  return None
93
101
  return CaseFileCollector.from_parent(parent, path=file_path)
94
102
 
95
103
 
104
+ @pytest.hookimpl(tryfirst=True)
105
+ def pytest_pycollect_makemodule(module_path: Path, parent: pytest.Collector):
106
+ """Collect a case file named on the command line as a case file, not a module.
107
+
108
+ pytest collects any `.py` path it is given explicitly as a test module whatever
109
+ its name, which would import the case file and run every assertion at import.
110
+ Answering this hook first hands the file to the case file collector instead.
111
+ """
112
+ if not _is_case_file(module_path, parent.config):
113
+ return None
114
+ return CaseFileCollector.from_parent(parent, path=module_path)
115
+
116
+
96
117
  def _diagnostics(config: pytest.Config, checker_name: str) -> dict[Path, list[Diagnostic]]:
97
118
  """Run one checker once per session and cache its result on the config."""
98
119
  cache = getattr(config, _DIAGNOSTICS, None)
@@ -104,15 +125,17 @@ def _diagnostics(config: pytest.Config, checker_name: str) -> dict[Path, list[Di
104
125
  assert directory is not None # only reachable from a collected case file
105
126
  root = Path(config.rootpath)
106
127
  checker = get_checker(checker_name)
107
- # A cache directory per checker and per xdist worker: the run is cheap once
108
- # warm, and sharing one between concurrent workers is what makes it stale.
109
- worker = getattr(config, 'workerinput', {}).get('workerid', 'master')
110
- cache[checker_name] = checker.run(
111
- '.'.join(directory.relative_to(root).parts),
112
- root=root,
113
- cache_dir=root / '.mypy_cache' / f'type_assert-{checker_name}-{worker}',
114
- extra_args=[str(arg) for arg in config.getini(checker_args_ini(checker_name))],
115
- )
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)
116
139
  return cache[checker_name]
117
140
 
118
141
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: type-assert
3
- Version: 0.2.0
3
+ Version: 0.2.2
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
@@ -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
  ]
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.2.0",
2
+ "tag": "0.2.2",
3
3
  "distance": 0,
4
- "node": "gd0866227dda697e3746b9f598708149b1e630bc0",
4
+ "node": "g1137e2bda7f3404391196296360e318d223606ec",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-09-05"
File without changes
File without changes
File without changes
File without changes