throngtest 0.0.1__py3-none-any.whl

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.
throngtest/__init__.py ADDED
File without changes
throngtest/coverage.py ADDED
@@ -0,0 +1,157 @@
1
+ """Coverage agents discovered through a pristan slot."""
2
+
3
+ import os
4
+ from abc import ABC, abstractmethod
5
+ from importlib import import_module
6
+ from pathlib import Path, PurePosixPath, PureWindowsPath
7
+ from typing import TYPE_CHECKING, Callable, Dict, List, Optional, cast
8
+
9
+ import pytest
10
+ from pristan import slot
11
+
12
+ from throngtest.coverage_transport import worker_data_file
13
+
14
+ if TYPE_CHECKING: # pragma: no cover - this import only exists for static analysis.
15
+ from coverage import Coverage
16
+
17
+
18
+ class CoverageAgent(ABC):
19
+ """Adapt one coverage provider to the isolate lifecycle."""
20
+
21
+ @abstractmethod
22
+ def active(self, config: pytest.Config) -> bool:
23
+ ... # pragma: no cover - abstract signature.
24
+
25
+ @abstractmethod
26
+ def start_worker(self, marker: str, configuration: Dict[str, object], root: Path) -> None:
27
+ ... # pragma: no cover - abstract signature.
28
+
29
+ def configuration(self, _root: Path) -> Dict[str, object]:
30
+ """Return JSON-safe settings needed by this agent in an isolate."""
31
+ return {}
32
+
33
+ def pytest_arguments(self) -> List[str]:
34
+ """Override provider options that must be evaluated by the controller."""
35
+ return []
36
+
37
+ @abstractmethod
38
+ def stop_worker(self) -> None:
39
+ ... # pragma: no cover - abstract signature.
40
+
41
+ def data_file(self) -> str:
42
+ """Return the provider's configured coverage data file in this process."""
43
+ from coverage import Coverage # noqa: PLC0415
44
+ current = Coverage.current()
45
+ return str(current.config.data_file if current is not None else Coverage().config.data_file)
46
+
47
+
48
+ @slot(entrypoint_group='throngtest.coverage')
49
+ def coverage_agents() -> Dict[str, CoverageAgent]: # type: ignore[empty-body]
50
+ ... # pragma: no cover - pristan replaces the slot body.
51
+
52
+
53
+ class PythonCoverageAgent(CoverageAgent):
54
+ """Start coverage.py in a worker created by ``coverage run``."""
55
+
56
+ def __init__(self) -> None:
57
+ self.started: Optional['Coverage'] = None
58
+ self.config_path: Optional[Path] = None
59
+
60
+ def active(self, config: pytest.Config) -> bool:
61
+ try:
62
+ from coverage import Coverage # noqa: PLC0415
63
+ except ImportError:
64
+ return False
65
+ return Coverage.current() is not None and not bool(cast(Optional[List[str]], config.getoption('cov_source', default=None)))
66
+
67
+ def configuration(self, root: Path) -> Dict[str, object]:
68
+ from coverage import Coverage # noqa: PLC0415
69
+ current = Coverage.current()
70
+ assert current is not None
71
+ filename = current.config.config_file
72
+ path = Path(filename) if filename else None
73
+ return {
74
+ 'suffix': path.suffix if path else '',
75
+ 'text': path.read_text() if path and path.is_file() else '',
76
+ 'source': current.config.source,
77
+ 'branch': current.config.branch,
78
+ 'root': str(root),
79
+ }
80
+
81
+ def start_worker(self, marker: str, configuration: Dict[str, object], root: Path) -> None:
82
+ from coverage import Coverage # noqa: PLC0415
83
+ if Coverage.current() is None:
84
+ os.environ['COVERAGE_FILE'] = worker_data_file(marker)
85
+ contents = cast(str, configuration.get('text', ''))
86
+ if contents:
87
+ suffix = cast(str, configuration.get('suffix', ''))
88
+ self.config_path = Path('.throngtest-coverage-config-' + marker.rstrip(':') + suffix)
89
+ self.config_path.write_text(contents)
90
+ original_source = cast(Optional[List[str]], configuration.get('source'))
91
+ source = original_source
92
+ controller_root = cast(str, configuration.get('root', ''))
93
+ if controller_root and original_source:
94
+ controller_path = PureWindowsPath(controller_root) if '\\' in controller_root or ':' in controller_root else PurePosixPath(controller_root)
95
+
96
+ def map_source(value: str) -> str:
97
+ try:
98
+ relative = type(controller_path)(value).relative_to(controller_path)
99
+ except ValueError:
100
+ return value
101
+ return str(root.joinpath(*relative.parts))
102
+
103
+ source = [map_source(value) for value in original_source]
104
+ self.started = Coverage(
105
+ config_file=str(self.config_path) if self.config_path else True,
106
+ source=source,
107
+ branch=cast(bool, configuration.get('branch')),
108
+ )
109
+ self.started.start()
110
+
111
+ def stop_worker(self) -> None:
112
+ if self.started is not None:
113
+ self.started.stop()
114
+ self.started.save()
115
+ if self.config_path is not None:
116
+ self.config_path.unlink()
117
+
118
+
119
+ class PytestCovAgent(CoverageAgent):
120
+ """Use pytest-cov's own worker lifecycle and collect its saved data."""
121
+
122
+ def __init__(self) -> None:
123
+ self.worker_file: Optional[str] = None
124
+
125
+ def active(self, config: pytest.Config) -> bool:
126
+ return bool(cast(Optional[List[str]], config.getoption('cov_source', default=None))) and not cast(bool, config.getoption('no_cov', default=False))
127
+
128
+ def start_worker(self, marker: str, _configuration: Dict[str, object], _root: Path) -> None:
129
+ # pytest-cov 6 and earlier starts a subprocess tracer before this
130
+ # module runs. Stop it before selecting a per-isolate data file.
131
+ try:
132
+ cleanup = cast(Callable[[], None], import_module('pytest_cov.embed').cleanup)
133
+ except (ImportError, AttributeError):
134
+ pass
135
+ else:
136
+ cleanup()
137
+ self.worker_file = worker_data_file(marker)
138
+ os.environ['COVERAGE_FILE'] = self.worker_file
139
+
140
+ def stop_worker(self) -> None:
141
+ pass
142
+
143
+ def data_file(self) -> str:
144
+ return self.worker_file or super().data_file()
145
+
146
+ def pytest_arguments(self) -> List[str]:
147
+ return ['--cov-fail-under=0']
148
+
149
+
150
+ @coverage_agents.plugin(unique=True)
151
+ def python_coverage() -> CoverageAgent:
152
+ return PythonCoverageAgent()
153
+
154
+
155
+ @coverage_agents.plugin(unique=True)
156
+ def pytest_cov() -> CoverageAgent:
157
+ return PytestCovAgent()
@@ -0,0 +1,93 @@
1
+ """Move coverage data through isolate command output, without shared storage."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import sys
7
+ from pathlib import Path, PurePosixPath, PureWindowsPath
8
+ from tempfile import TemporaryDirectory
9
+ from typing import Dict, List, Set, cast
10
+ from uuid import uuid4
11
+
12
+ from throngtest.protocol import Request, WorkerError, encode
13
+
14
+
15
+ def marker_path(marker: str) -> Path:
16
+ return Path('.throngtest-coverage-' + hashlib.sha256(marker.encode()).hexdigest())
17
+
18
+
19
+ def worker_data_file(marker: str) -> str:
20
+ return '.coverage.throngtest-' + hashlib.sha256(marker.encode()).hexdigest()
21
+
22
+
23
+ def export(value: str) -> None:
24
+ """Print all new coverage databases after the pytest process has exited."""
25
+ request = Request.unpack(value)
26
+ root = Path.cwd().resolve()
27
+ directory = root / request.directory
28
+ marker = directory / marker_path(request.marker)
29
+ since = marker.stat().st_mtime_ns
30
+ data_files = cast(Dict[str, str], json.loads(marker.read_text()))
31
+ files: Dict[str, List[str]] = {}
32
+ for name in request.coverage_agents or []:
33
+ candidates: Set[Path] = set()
34
+ data_file = Path(data_files[name])
35
+ if not data_file.is_absolute():
36
+ data_file = directory / data_file
37
+ candidates.update(root.rglob(data_file.name + '*'))
38
+ candidates.update(data_file.parent.glob(data_file.name + '*'))
39
+ files[name] = []
40
+ for path in sorted(candidates):
41
+ if path.is_file() and path.stat().st_mtime_ns >= since:
42
+ content = path.read_bytes()
43
+ if content.startswith(b'SQLite format 3\x00'):
44
+ files[name].append(base64.b64encode(content).decode('ascii'))
45
+ marker.unlink()
46
+ sys.stdout.write(request.marker + encode({'version': 1, 'root': str(root), 'files': files}) + '\n')
47
+
48
+
49
+ def receive(payload: Dict[str, object], targets: Dict[str, str], root: Path) -> None:
50
+ """Restore and remap coverage databases before the controller reports."""
51
+ remote = payload.get('root')
52
+ files = payload.get('files')
53
+ if not isinstance(remote, str) or not isinstance(files, dict):
54
+ raise WorkerError('isolate did not return coverage data')
55
+ from coverage import CoverageData # noqa: PLC0415
56
+ from coverage.exceptions import DataError # noqa: PLC0415
57
+ remote_path = PureWindowsPath(remote) if '\\' in remote or ':' in remote else PurePosixPath(remote)
58
+
59
+ def map_path(filename: str) -> str:
60
+ path = type(remote_path)(filename)
61
+ try:
62
+ relative = path.relative_to(remote_path)
63
+ except ValueError:
64
+ return filename
65
+ return str(root.joinpath(*relative.parts))
66
+
67
+ for name, target in targets.items():
68
+ records = files.get(name)
69
+ if not isinstance(records, list) or not records:
70
+ raise WorkerError(f'isolate did not return coverage data for {name}')
71
+ for item in cast(List[object], records):
72
+ if not isinstance(item, str):
73
+ raise WorkerError('isolate returned malformed coverage data')
74
+ try:
75
+ content = base64.b64decode(item, validate=True)
76
+ except ValueError as error:
77
+ raise WorkerError('isolate returned malformed coverage data') from error
78
+ if not content.startswith(b'SQLite format 3\x00'):
79
+ raise WorkerError('isolate returned malformed coverage data')
80
+ try:
81
+ with TemporaryDirectory(prefix='throngtest-coverage-') as temporary:
82
+ source = Path(temporary) / 'data'
83
+ source.write_bytes(content)
84
+ source_data = CoverageData(basename=str(source))
85
+ try:
86
+ source_data.read()
87
+ restored = CoverageData(basename=target, suffix=uuid4().hex)
88
+ restored.update(source_data, map_path=map_path)
89
+ restored.write()
90
+ finally:
91
+ source_data.erase()
92
+ except DataError as error:
93
+ raise WorkerError('isolate returned malformed coverage data') from error
@@ -0,0 +1,54 @@
1
+ """Deterministic partitions, shared by the controller and the workers."""
2
+
3
+ from difflib import unified_diff
4
+ from hashlib import sha256
5
+ from itertools import islice
6
+ from typing import Dict, List, Sequence
7
+
8
+
9
+ def fingerprint(nodeids: Sequence[str]) -> str:
10
+ # Length prefixes make the encoding unambiguous even for unusual node IDs.
11
+ digest = sha256()
12
+ for nodeid in nodeids:
13
+ value = nodeid.encode('utf-8')
14
+ digest.update(len(value).to_bytes(8, 'big'))
15
+ digest.update(value)
16
+ return digest.hexdigest()
17
+
18
+
19
+ def collection_difference(expected: Sequence[str], actual: Sequence[str]) -> str:
20
+ difference = list(islice(unified_diff(expected, actual, fromfile='controller', tofile='isolate', n=2, lineterm=''), 101))
21
+ if len(difference) > 100:
22
+ difference[-1] = '... collection diff truncated after 100 lines'
23
+ return '\n'.join([
24
+ 'test collection differs between the controller and the isolate',
25
+ 'Possible causes to check:',
26
+ ' * Test files were added, removed, or excluded from the isolate snapshot;',
27
+ ' preparation commands may generate or change files.',
28
+ ' * Parametrization or parameter IDs depend on environment variables, files,',
29
+ ' external data, random values, or the current time.',
30
+ ' * Collection order is unstable, for example parameters come from a set.',
31
+ ' * Test selection differs: check -k, -m, addopts, configuration,',
32
+ ' and installed plugins or dependencies.',
33
+ ' * Absolute paths in parameter IDs change when the project is copied',
34
+ ' to a different directory, even if the tests are otherwise equivalent.',
35
+ '',
36
+ f'controller: {len(expected)} selected tests',
37
+ f'isolate: {len(actual)} selected tests',
38
+ *difference,
39
+ ])
40
+
41
+
42
+ def partition(nodeids: Sequence[str], workers: int, mode: str) -> List[List[int]]:
43
+ if workers < 1:
44
+ raise ValueError('workers must be positive')
45
+ if mode not in ('tests', 'files'):
46
+ raise ValueError('distribution must be tests or files')
47
+ groups: Dict[str, List[int]] = {}
48
+ for index, nodeid in enumerate(nodeids):
49
+ key = nodeid.split('::', 1)[0] if mode == 'files' else str(index)
50
+ groups.setdefault(key, []).append(index)
51
+ shards: List[List[int]] = [[] for _ in range(min(workers, len(groups)))]
52
+ for group in sorted(groups.values(), key=len, reverse=True):
53
+ min(shards, key=len).extend(group)
54
+ return [sorted(shard) for shard in shards]
throngtest/plugin.py ADDED
@@ -0,0 +1,56 @@
1
+ """Pytest entry point. Distribution is enabled by default with up to four isolates."""
2
+
3
+ from typing import Dict, Generator, List, Optional, cast
4
+
5
+ import pytest
6
+
7
+ from throngtest.protocol import Request
8
+ from throngtest.runner import Runner
9
+ from throngtest.settings import ARGUMENTS, WORKER, read_settings
10
+ from throngtest.xdist import Shard, suspend
11
+
12
+
13
+ def pytest_addoption(parser: pytest.Parser) -> None:
14
+ group = parser.getgroup('throngtest', 'Run test subsets in throng isolates')
15
+ group.addoption('--isolates', dest='throngtest_workers', metavar='COUNT', default=None,
16
+ help='Maximum isolate count; 0 disables distribution (default: 4)')
17
+ for name, description in (
18
+ ('backend', 'Throng plugin name (default: temporary_directory)'),
19
+ ('distribution', 'Partition by tests or files (default: tests)'),
20
+ ('python', 'Python executable available inside the isolate'),
21
+ ('exclude', 'JSON list of patterns excluded from the isolate snapshot'),
22
+ ('preparation', 'JSON list of commands run in each isolate before pytest (default: [])'),
23
+ ):
24
+ group.addoption(f'--throngtest-{name}', default=None, help=description)
25
+ group.addoption('--throngtest-check-fingerprints', action='store_const', const=True, default=None,
26
+ dest='throngtest_check_fingerprints', help='Require identical ordered test collections in all isolates (default: disabled)')
27
+ group.addoption('--throngtest-no-check-fingerprints', action='store_const', const=False, default=None,
28
+ dest='throngtest_check_fingerprints', help='Disable collection fingerprint checks, overriding environment and TOML settings')
29
+
30
+
31
+ def pytest_load_initial_conftests(early_config: pytest.Config, args: List[str]) -> None:
32
+ early_config.stash[ARGUMENTS] = list(args)
33
+
34
+
35
+ @pytest.hookimpl(wrapper=True, tryfirst=True) # type: ignore[misc]
36
+ def pytest_cmdline_main(config: pytest.Config) -> Generator[None, Optional[int], Optional[int]]:
37
+ if not config.stash.get(WORKER, False) and not hasattr(config, 'workerinput') and read_settings(config).workers:
38
+ suspend(config)
39
+ return (yield)
40
+
41
+
42
+ @pytest.hookimpl(trylast=True) # type: ignore[misc] # pluggy's decorator bound includes Any.
43
+ def pytest_configure(config: pytest.Config) -> None:
44
+ workerinput = cast(Optional[Dict[str, object]], getattr(config, 'workerinput', None)) or {}
45
+ if 'throngtest' in workerinput:
46
+ config.stash[WORKER] = True
47
+ config.pluginmanager.register(Shard(Request.unpack(cast(str, workerinput['throngtest']))), 'throngtest-shard')
48
+ if config.stash.get(WORKER, False):
49
+ return
50
+ settings = read_settings(config)
51
+ if settings.workers:
52
+ if cast(bool, config.getoption('usepdb')):
53
+ raise pytest.UsageError('throngtest cannot be combined with --pdb')
54
+ if any(cast(object, config.getoption(name, default=False)) for name in ('lf', 'failedfirst', 'newfirst', 'stepwise')):
55
+ raise pytest.UsageError('throngtest does not support cache-based selection (--lf/--ff/--nf) or --stepwise')
56
+ config.pluginmanager.register(Runner(settings), 'throngtest-runner')
throngtest/protocol.py ADDED
@@ -0,0 +1,60 @@
1
+ """A versioned, JSON-only protocol carried by throng's command output."""
2
+
3
+ import base64
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Dict, List, Optional, cast
7
+
8
+
9
+ class WorkerError(Exception):
10
+ """An isolate failed to produce a complete, trustworthy pytest result."""
11
+
12
+ def __init__(self, message: str, exitcode: int = 3) -> None:
13
+ super().__init__(message)
14
+ self.exitcode = exitcode
15
+
16
+
17
+ def encode(data: Dict[str, object]) -> str:
18
+ return base64.b64encode(json.dumps(data, ensure_ascii=True).encode('ascii')).decode('ascii')
19
+
20
+
21
+ def decode(value: str) -> Dict[str, object]:
22
+ result = cast(object, json.loads(base64.b64decode(value, validate=True)))
23
+ if not isinstance(result, dict):
24
+ raise ValueError('expected a JSON object')
25
+ return cast(Dict[str, object], result)
26
+
27
+
28
+ @dataclass
29
+ class Request:
30
+ arguments: List[str]
31
+ fingerprint: Optional[str]
32
+ workers: int
33
+ distribution: str
34
+ shard: int
35
+ marker: str
36
+ directory: str
37
+ coverage_agents: Optional[List[str]] = None
38
+ coverage_targets: Optional[Dict[str, str]] = None
39
+ coverage_configurations: Optional[Dict[str, Dict[str, object]]] = None
40
+
41
+ def pack(self) -> str:
42
+ return encode(cast(Dict[str, object], vars(self)))
43
+
44
+ @classmethod
45
+ def unpack(cls, value: str) -> 'Request':
46
+ data = decode(value)
47
+ return cls(**data) # type: ignore[arg-type]
48
+
49
+
50
+ def read_response(stdout: str, marker: str) -> Dict[str, object]:
51
+ for line in reversed(stdout.splitlines()):
52
+ if line.startswith(marker):
53
+ try:
54
+ response = decode(line[len(marker):])
55
+ except ValueError as error:
56
+ raise WorkerError(f'invalid worker response: {error}') from error
57
+ if response.get('version') != 1:
58
+ raise WorkerError('unsupported worker protocol version')
59
+ return response
60
+ raise WorkerError('worker terminated without a result')
throngtest/py.typed ADDED
File without changes