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 +0 -0
- throngtest/coverage.py +157 -0
- throngtest/coverage_transport.py +93 -0
- throngtest/distribution.py +54 -0
- throngtest/plugin.py +56 -0
- throngtest/protocol.py +60 -0
- throngtest/py.typed +0 -0
- throngtest/runner.py +333 -0
- throngtest/settings.py +69 -0
- throngtest/worker.py +171 -0
- throngtest/xdist.py +86 -0
- throngtest-0.0.1.dist-info/LICENSE +131 -0
- throngtest-0.0.1.dist-info/METADATA +336 -0
- throngtest-0.0.1.dist-info/RECORD +17 -0
- throngtest-0.0.1.dist-info/WHEEL +5 -0
- throngtest-0.0.1.dist-info/entry_points.txt +2 -0
- throngtest-0.0.1.dist-info/top_level.txt +1 -0
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
|