pytest-hexaqueue 0.6.0__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.
- pytest_hexaqueue/__init__.py +74 -0
- pytest_hexaqueue/adapters/__init__.py +17 -0
- pytest_hexaqueue/adapters/client.py +188 -0
- pytest_hexaqueue/adapters/slicer.py +127 -0
- pytest_hexaqueue/domain/__init__.py +21 -0
- pytest_hexaqueue/domain/delta.py +38 -0
- pytest_hexaqueue/domain/outcome.py +101 -0
- pytest_hexaqueue/domain/slice.py +109 -0
- pytest_hexaqueue/infra/__init__.py +17 -0
- pytest_hexaqueue/infra/plugin.py +516 -0
- pytest_hexaqueue/ports/__init__.py +9 -0
- pytest_hexaqueue/ports/client.py +48 -0
- pytest_hexaqueue/ports/slicer.py +39 -0
- pytest_hexaqueue-0.6.0.dist-info/METADATA +218 -0
- pytest_hexaqueue-0.6.0.dist-info/RECORD +17 -0
- pytest_hexaqueue-0.6.0.dist-info/WHEEL +4 -0
- pytest_hexaqueue-0.6.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""pytest-hexaqueue - Distributed Test Slicing, Remote Execution, and Multi-CSP Free-Tier Runner Plugin."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"adapters",
|
|
8
|
+
"domain",
|
|
9
|
+
"FileContiguousTestSlicer",
|
|
10
|
+
"HexaqueueTestDispatchClient",
|
|
11
|
+
"HexaqueueTestDispatchPort",
|
|
12
|
+
"infra",
|
|
13
|
+
"InMemoryTestDispatchClient",
|
|
14
|
+
"ports",
|
|
15
|
+
"RoundRobinTestSlicer",
|
|
16
|
+
"SliceOutcomeReport",
|
|
17
|
+
"TestItemInfo",
|
|
18
|
+
"TestOutcomeItem",
|
|
19
|
+
"TestRunSession",
|
|
20
|
+
"TestSlicerPort",
|
|
21
|
+
"TestSliceSpec",
|
|
22
|
+
"WorkspaceDeltaBundle",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_EXPORTS: dict[str, tuple[str, str]] = {
|
|
26
|
+
"FileContiguousTestSlicer": (
|
|
27
|
+
"pytest_hexaqueue.adapters.slicer",
|
|
28
|
+
"FileContiguousTestSlicer",
|
|
29
|
+
),
|
|
30
|
+
"HexaqueueTestDispatchClient": (
|
|
31
|
+
"pytest_hexaqueue.adapters.client",
|
|
32
|
+
"HexaqueueTestDispatchClient",
|
|
33
|
+
),
|
|
34
|
+
"HexaqueueTestDispatchPort": (
|
|
35
|
+
"pytest_hexaqueue.ports.client",
|
|
36
|
+
"HexaqueueTestDispatchPort",
|
|
37
|
+
),
|
|
38
|
+
"InMemoryTestDispatchClient": (
|
|
39
|
+
"pytest_hexaqueue.adapters.client",
|
|
40
|
+
"InMemoryTestDispatchClient",
|
|
41
|
+
),
|
|
42
|
+
"RoundRobinTestSlicer": (
|
|
43
|
+
"pytest_hexaqueue.adapters.slicer",
|
|
44
|
+
"RoundRobinTestSlicer",
|
|
45
|
+
),
|
|
46
|
+
"SliceOutcomeReport": ("pytest_hexaqueue.domain.outcome", "SliceOutcomeReport"),
|
|
47
|
+
"TestItemInfo": ("pytest_hexaqueue.domain.slice", "TestItemInfo"),
|
|
48
|
+
"TestOutcomeItem": ("pytest_hexaqueue.domain.outcome", "TestOutcomeItem"),
|
|
49
|
+
"TestRunSession": ("pytest_hexaqueue.domain.slice", "TestRunSession"),
|
|
50
|
+
"TestSlicerPort": ("pytest_hexaqueue.ports.slicer", "TestSlicerPort"),
|
|
51
|
+
"TestSliceSpec": ("pytest_hexaqueue.domain.slice", "TestSliceSpec"),
|
|
52
|
+
"WorkspaceDeltaBundle": ("pytest_hexaqueue.domain.delta", "WorkspaceDeltaBundle"),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def __getattr__(name: str) -> Any:
|
|
57
|
+
"""Lazily load symbols upon attribute access."""
|
|
58
|
+
if name in _EXPORTS:
|
|
59
|
+
mod_name, attr_name = _EXPORTS[name]
|
|
60
|
+
mod = importlib.import_module(mod_name)
|
|
61
|
+
val = getattr(mod, attr_name)
|
|
62
|
+
globals()[name] = val
|
|
63
|
+
return val
|
|
64
|
+
if name in ("adapters", "domain", "infra", "ports"):
|
|
65
|
+
mod = importlib.import_module(f"pytest_hexaqueue.{name}")
|
|
66
|
+
globals()[name] = mod
|
|
67
|
+
return mod
|
|
68
|
+
msg = f"module {__name__!r} has no attribute {name!r}"
|
|
69
|
+
raise AttributeError(msg)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def __dir__() -> list[str]:
|
|
73
|
+
"""Return public export list including lazy symbols."""
|
|
74
|
+
return sorted(set(list(globals().keys()) + __all__))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Adapter implementations for pytest-hexaqueue."""
|
|
2
|
+
|
|
3
|
+
from pytest_hexaqueue.adapters.client import (
|
|
4
|
+
HexaqueueTestDispatchClient,
|
|
5
|
+
InMemoryTestDispatchClient,
|
|
6
|
+
)
|
|
7
|
+
from pytest_hexaqueue.adapters.slicer import (
|
|
8
|
+
FileContiguousTestSlicer,
|
|
9
|
+
RoundRobinTestSlicer,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"FileContiguousTestSlicer",
|
|
14
|
+
"HexaqueueTestDispatchClient",
|
|
15
|
+
"InMemoryTestDispatchClient",
|
|
16
|
+
"RoundRobinTestSlicer",
|
|
17
|
+
]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Test dispatch client adapters connecting pytest to Hexaqueue clusters.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Provides HTTP/REST communication with hexaqueue-server for remote execution
|
|
5
|
+
alongside InMemoryTestDispatchClient for hermetic testing, local fallbacks,
|
|
6
|
+
and property test verification.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from pytest_hexaqueue.domain.delta import WorkspaceDeltaBundle
|
|
14
|
+
from pytest_hexaqueue.domain.outcome import (
|
|
15
|
+
SliceOutcomeReport,
|
|
16
|
+
TestOutcomeItem,
|
|
17
|
+
)
|
|
18
|
+
from pytest_hexaqueue.domain.slice import TestRunSession
|
|
19
|
+
from pytest_hexaqueue.ports.client import HexaqueueTestDispatchPort
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HexaqueueTestDispatchClient(HexaqueueTestDispatchPort):
|
|
23
|
+
"""Production client dispatching test runs to Hexaqueue cluster controllers.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
server_url: Base URL of the Hexaqueue controller.
|
|
27
|
+
timeout_seconds: HTTP request timeout limit.
|
|
28
|
+
api_token: Optional Bearer authentication token for secure control-plane communication.
|
|
29
|
+
|
|
30
|
+
Notes/Architectural Intent:
|
|
31
|
+
Enforces separation of control-plane authentication from compute-plane CSP credentials.
|
|
32
|
+
The client passes an optional Bearer token to authenticate with the Hexaqueue cluster
|
|
33
|
+
controller without requiring any CSP admin keys or cloud SDK access on the test runner.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
server_url: str = "http://127.0.0.1:8000",
|
|
39
|
+
timeout_seconds: float = 30.0,
|
|
40
|
+
api_token: str | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.server_url = server_url.rstrip("/")
|
|
43
|
+
self.timeout_seconds = timeout_seconds
|
|
44
|
+
self.api_token = api_token
|
|
45
|
+
|
|
46
|
+
def _headers(self) -> dict[str, str]:
|
|
47
|
+
"""Build request headers including Bearer authorization when configured."""
|
|
48
|
+
headers: dict[str, str] = {}
|
|
49
|
+
if self.api_token:
|
|
50
|
+
headers["Authorization"] = f"Bearer {self.api_token}"
|
|
51
|
+
return headers
|
|
52
|
+
|
|
53
|
+
async def health_check(self) -> bool:
|
|
54
|
+
"""Query cluster controller health status endpoint."""
|
|
55
|
+
try:
|
|
56
|
+
async with httpx.AsyncClient(
|
|
57
|
+
timeout=self.timeout_seconds, headers=self._headers()
|
|
58
|
+
) as client:
|
|
59
|
+
resp = await client.get(f"{self.server_url}/health")
|
|
60
|
+
return resp.status_code == 200
|
|
61
|
+
except (httpx.HTTPError, OSError):
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
async def dispatch_run(
|
|
65
|
+
self,
|
|
66
|
+
session: TestRunSession,
|
|
67
|
+
bundle: WorkspaceDeltaBundle | None = None,
|
|
68
|
+
) -> list[SliceOutcomeReport]:
|
|
69
|
+
"""Dispatch test slices to controller via HTTP API."""
|
|
70
|
+
payload: dict[str, Any] = {
|
|
71
|
+
"session_id": session.session_id,
|
|
72
|
+
"pool": session.pool,
|
|
73
|
+
"free_tier": session.free_tier,
|
|
74
|
+
"container": session.container,
|
|
75
|
+
"container_image": session.container_image,
|
|
76
|
+
"coverage_sources": session.coverage_sources,
|
|
77
|
+
"slices": [s.model_dump() for s in session.slices],
|
|
78
|
+
"bundle": bundle.model_dump() if bundle else None,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
async with httpx.AsyncClient(
|
|
83
|
+
timeout=self.timeout_seconds, headers=self._headers()
|
|
84
|
+
) as client:
|
|
85
|
+
resp = await client.post(
|
|
86
|
+
f"{self.server_url}/api/v1/test/runs",
|
|
87
|
+
json=payload,
|
|
88
|
+
)
|
|
89
|
+
if resp.status_code != 200:
|
|
90
|
+
msg = f"Failed to submit test run: HTTP {resp.status_code} - {resp.text}"
|
|
91
|
+
raise ConnectionError(msg)
|
|
92
|
+
|
|
93
|
+
raw_reports = resp.json().get("reports", [])
|
|
94
|
+
return [SliceOutcomeReport.model_validate(r) for r in raw_reports]
|
|
95
|
+
except (httpx.HTTPError, OSError) as exc:
|
|
96
|
+
msg = f"Hexaqueue cluster at '{self.server_url}' is unreachable: {exc}"
|
|
97
|
+
raise ConnectionError(msg) from exc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class InMemoryTestDispatchClient(HexaqueueTestDispatchPort):
|
|
101
|
+
"""In-memory simulation and test oracle for hermetic pytest execution.
|
|
102
|
+
|
|
103
|
+
Notes/Architectural Intent:
|
|
104
|
+
Enables offline developer workflows, dual-oracle state testing, and local fallback
|
|
105
|
+
by simulating multi-worker distributed slice execution in-process.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
default_provider: str = "LOCAL",
|
|
111
|
+
is_free_tier: bool = False,
|
|
112
|
+
fail_nodeids: set[str] | None = None,
|
|
113
|
+
is_healthy: bool = True,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.default_provider = default_provider
|
|
116
|
+
self.is_free_tier = is_free_tier
|
|
117
|
+
self.fail_nodeids = fail_nodeids or set()
|
|
118
|
+
self.is_healthy = is_healthy
|
|
119
|
+
self.dispatched_sessions: list[TestRunSession] = []
|
|
120
|
+
|
|
121
|
+
async def health_check(self) -> bool:
|
|
122
|
+
"""Return configured simulated health status."""
|
|
123
|
+
return self.is_healthy
|
|
124
|
+
|
|
125
|
+
async def dispatch_run(
|
|
126
|
+
self,
|
|
127
|
+
session: TestRunSession,
|
|
128
|
+
bundle: WorkspaceDeltaBundle | None = None,
|
|
129
|
+
) -> list[SliceOutcomeReport]:
|
|
130
|
+
"""Simulate distributed execution of each slice across mock providers."""
|
|
131
|
+
if not self.is_healthy:
|
|
132
|
+
msg = "Simulated cluster is unhealthy or unreachable"
|
|
133
|
+
raise ConnectionError(msg)
|
|
134
|
+
|
|
135
|
+
self.dispatched_sessions.append(session)
|
|
136
|
+
reports: list[SliceOutcomeReport] = []
|
|
137
|
+
|
|
138
|
+
# Simulated CSP pool cycle for multi-cloud demonstration
|
|
139
|
+
providers = ["OCI", "GCP", "AWS", "AZURE", "LOCAL"]
|
|
140
|
+
|
|
141
|
+
for slice_spec in session.slices:
|
|
142
|
+
assigned_provider = (
|
|
143
|
+
providers[slice_spec.slice_index % len(providers)]
|
|
144
|
+
if session.free_tier
|
|
145
|
+
else self.default_provider
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
outcomes: list[TestOutcomeItem] = []
|
|
149
|
+
for nodeid in slice_spec.test_nodeids:
|
|
150
|
+
if nodeid in self.fail_nodeids:
|
|
151
|
+
outcomes.append(
|
|
152
|
+
TestOutcomeItem(
|
|
153
|
+
nodeid=nodeid,
|
|
154
|
+
outcome="failed",
|
|
155
|
+
duration_seconds=0.05,
|
|
156
|
+
longrepr=f"AssertionError: Simulated test failure for {nodeid}",
|
|
157
|
+
stderr="Captured error log",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
outcomes.append(
|
|
162
|
+
TestOutcomeItem(
|
|
163
|
+
nodeid=nodeid,
|
|
164
|
+
outcome="passed",
|
|
165
|
+
duration_seconds=0.02,
|
|
166
|
+
stdout="Captured stdout trace",
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
reports.append(
|
|
171
|
+
SliceOutcomeReport(
|
|
172
|
+
slice_id=slice_spec.slice_id,
|
|
173
|
+
slice_index=slice_spec.slice_index,
|
|
174
|
+
worker_id=f"worker-{assigned_provider.lower()}-{slice_spec.slice_index}",
|
|
175
|
+
provider=assigned_provider,
|
|
176
|
+
is_free_tier=session.free_tier,
|
|
177
|
+
outcomes=outcomes,
|
|
178
|
+
duration_seconds=max(0.1, len(slice_spec.test_nodeids) * 0.02),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return reports
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
__all__ = [
|
|
186
|
+
"HexaqueueTestDispatchClient",
|
|
187
|
+
"InMemoryTestDispatchClient",
|
|
188
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Concrete test slicing adapters for distributing pytest items across execution slices.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Provides deterministic test partitioning algorithms: RoundRobinTestSlicer for
|
|
5
|
+
uniform distribution and FileContiguousTestSlicer to preserve module fixture
|
|
6
|
+
and setup locality.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
|
|
11
|
+
from pytest_hexaqueue.domain.slice import TestItemInfo, TestSliceSpec
|
|
12
|
+
from pytest_hexaqueue.ports.slicer import TestSlicerPort
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RoundRobinTestSlicer(TestSlicerPort):
|
|
16
|
+
"""Partitions test items evenly across slices using round-robin distribution.
|
|
17
|
+
|
|
18
|
+
Notes/Architectural Intent:
|
|
19
|
+
Guarantees that test counts per slice differ by at most 1, achieving
|
|
20
|
+
near-optimal load balance for uniform duration test suites.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__test__ = False
|
|
24
|
+
|
|
25
|
+
def slice_items(
|
|
26
|
+
self, items: list[TestItemInfo], num_slices: int
|
|
27
|
+
) -> list[TestSliceSpec]:
|
|
28
|
+
"""Distribute test items cyclically across slices.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
items: List of collected TestItemInfo instances.
|
|
32
|
+
num_slices: Number of slices to generate.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
List of partitioned TestSliceSpec instances.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: If num_slices is less than 1.
|
|
39
|
+
"""
|
|
40
|
+
if num_slices < 1:
|
|
41
|
+
msg = f"num_slices must be >= 1, got {num_slices}"
|
|
42
|
+
raise ValueError(msg)
|
|
43
|
+
|
|
44
|
+
nodeid_buckets: list[list[str]] = [[] for _ in range(num_slices)]
|
|
45
|
+
duration_buckets: list[float] = [0.0 for _ in range(num_slices)]
|
|
46
|
+
|
|
47
|
+
for i, item in enumerate(items):
|
|
48
|
+
bucket_idx = i % num_slices
|
|
49
|
+
nodeid_buckets[bucket_idx].append(item.nodeid)
|
|
50
|
+
duration_buckets[bucket_idx] += item.estimated_duration
|
|
51
|
+
|
|
52
|
+
return [
|
|
53
|
+
TestSliceSpec(
|
|
54
|
+
slice_id=f"slice-{idx}",
|
|
55
|
+
slice_index=idx,
|
|
56
|
+
test_nodeids=nodeid_buckets[idx],
|
|
57
|
+
total_estimated_duration=round(duration_buckets[idx], 2),
|
|
58
|
+
total_slices=num_slices,
|
|
59
|
+
)
|
|
60
|
+
for idx in range(num_slices)
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FileContiguousTestSlicer(TestSlicerPort):
|
|
65
|
+
"""Partitions test items grouping by test module to preserve fixture locality.
|
|
66
|
+
|
|
67
|
+
Notes/Architectural Intent:
|
|
68
|
+
Groups all test functions from the same test file into the same slice
|
|
69
|
+
whenever possible, avoiding repeated module imports and costly module-scoped
|
|
70
|
+
or session-scoped fixture initializations across multiple worker nodes.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
__test__ = False
|
|
74
|
+
|
|
75
|
+
def slice_items(
|
|
76
|
+
self, items: list[TestItemInfo], num_slices: int
|
|
77
|
+
) -> list[TestSliceSpec]:
|
|
78
|
+
"""Group test items by file into balanced slices.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
items: List of collected TestItemInfo instances.
|
|
82
|
+
num_slices: Number of slices to generate.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of partitioned TestSliceSpec instances.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValueError: If num_slices is less than 1.
|
|
89
|
+
"""
|
|
90
|
+
if num_slices < 1:
|
|
91
|
+
msg = f"num_slices must be >= 1, got {num_slices}"
|
|
92
|
+
raise ValueError(msg)
|
|
93
|
+
|
|
94
|
+
# Group items by file path
|
|
95
|
+
by_file: dict[str, list[TestItemInfo]] = defaultdict(list)
|
|
96
|
+
for item in items:
|
|
97
|
+
by_file[item.file_path].append(item)
|
|
98
|
+
|
|
99
|
+
nodeid_buckets: list[list[str]] = [[] for _ in range(num_slices)]
|
|
100
|
+
duration_buckets: list[float] = [0.0 for _ in range(num_slices)]
|
|
101
|
+
|
|
102
|
+
# Greedy bin-packing of file groups by count/duration
|
|
103
|
+
for _file_path, file_items in sorted(
|
|
104
|
+
by_file.items(), key=lambda x: len(x[1]), reverse=True
|
|
105
|
+
):
|
|
106
|
+
# Select bucket with minimal assigned duration
|
|
107
|
+
min_idx = duration_buckets.index(min(duration_buckets))
|
|
108
|
+
for item in file_items:
|
|
109
|
+
nodeid_buckets[min_idx].append(item.nodeid)
|
|
110
|
+
duration_buckets[min_idx] += item.estimated_duration
|
|
111
|
+
|
|
112
|
+
return [
|
|
113
|
+
TestSliceSpec(
|
|
114
|
+
slice_id=f"slice-{idx}",
|
|
115
|
+
slice_index=idx,
|
|
116
|
+
test_nodeids=nodeid_buckets[idx],
|
|
117
|
+
total_estimated_duration=round(duration_buckets[idx], 2),
|
|
118
|
+
total_slices=num_slices,
|
|
119
|
+
)
|
|
120
|
+
for idx in range(num_slices)
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"FileContiguousTestSlicer",
|
|
126
|
+
"RoundRobinTestSlicer",
|
|
127
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Domain models and algorithms for pytest-hexaqueue distributed testing."""
|
|
2
|
+
|
|
3
|
+
from pytest_hexaqueue.domain.delta import WorkspaceDeltaBundle
|
|
4
|
+
from pytest_hexaqueue.domain.outcome import (
|
|
5
|
+
SliceOutcomeReport,
|
|
6
|
+
TestOutcomeItem,
|
|
7
|
+
)
|
|
8
|
+
from pytest_hexaqueue.domain.slice import (
|
|
9
|
+
TestItemInfo,
|
|
10
|
+
TestRunSession,
|
|
11
|
+
TestSliceSpec,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"SliceOutcomeReport",
|
|
16
|
+
"TestItemInfo",
|
|
17
|
+
"TestOutcomeItem",
|
|
18
|
+
"TestRunSession",
|
|
19
|
+
"TestSliceSpec",
|
|
20
|
+
"WorkspaceDeltaBundle",
|
|
21
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Domain models for workspace delta snapshotting and content-addressed test code synchronization.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Captures file manifests and cryptographic SHA-256 hashes of test source code,
|
|
5
|
+
enabling incremental delta synchronization to remote cluster workers via
|
|
6
|
+
Hexaqueue Collateral CAS storage without syncing unmodified files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkspaceDeltaBundle(BaseModel):
|
|
13
|
+
"""Manifest of workspace files and cryptographic SHA-256 content digests.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
bundle_id: Content-addressed identifier (hexadecimal SHA-256).
|
|
17
|
+
git_commit: Optional base Git commit SHA.
|
|
18
|
+
file_hashes: Mapping of relative file paths to their SHA-256 checksums.
|
|
19
|
+
total_files: Total count of files included in the delta bundle.
|
|
20
|
+
total_bytes: Total size of all files in bytes.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
24
|
+
|
|
25
|
+
bundle_id: str = Field(description="Content-addressed bundle SHA-256 digest")
|
|
26
|
+
file_hashes: dict[str, str] = Field(
|
|
27
|
+
default_factory=dict, description="Relative file paths to SHA-256 digests"
|
|
28
|
+
)
|
|
29
|
+
git_commit: str | None = Field(
|
|
30
|
+
default=None, description="Current Git head commit hash"
|
|
31
|
+
)
|
|
32
|
+
total_bytes: int = Field(ge=0, description="Total size in bytes")
|
|
33
|
+
total_files: int = Field(ge=0, description="Total files count")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"WorkspaceDeltaBundle",
|
|
38
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Domain models for distributed test execution outcomes, reports, and coverage.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Encapsulates granular test outcomes, terminal slice reports, and execution
|
|
5
|
+
telemetry streamed back from remote cluster workers or local fallbacks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestOutcomeItem(BaseModel):
|
|
14
|
+
"""Execution outcome of a single test item.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
nodeid: Pytest test node identifier.
|
|
18
|
+
outcome: Terminal verdict ('passed', 'failed', 'skipped', 'xfail', 'xpass').
|
|
19
|
+
duration_seconds: Test execution duration in seconds.
|
|
20
|
+
longrepr: Optional formatted traceback or skip/failure message.
|
|
21
|
+
stdout: Standard output captured during test execution.
|
|
22
|
+
stderr: Standard error captured during test execution.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
__test__ = False
|
|
26
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
27
|
+
|
|
28
|
+
duration_seconds: float = Field(
|
|
29
|
+
default=0.0, ge=0.0, description="Duration in seconds"
|
|
30
|
+
)
|
|
31
|
+
longrepr: str | None = Field(
|
|
32
|
+
default=None, description="Traceback or explanation message"
|
|
33
|
+
)
|
|
34
|
+
nodeid: str = Field(description="Pytest node ID")
|
|
35
|
+
outcome: str = Field(description="Outcome verdict")
|
|
36
|
+
stderr: str | None = Field(default=None, description="Captured stderr")
|
|
37
|
+
stdout: str | None = Field(default=None, description="Captured stdout")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SliceOutcomeReport(BaseModel):
|
|
41
|
+
"""Aggregate execution report for a completed test slice.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
slice_id: Slice identifier.
|
|
45
|
+
slice_index: Zero-based slice index.
|
|
46
|
+
worker_id: Worker instance identifier hosting the execution.
|
|
47
|
+
provider: Hosting Cloud Service Provider or platform name.
|
|
48
|
+
is_free_tier: Whether the slice executed under Free-Tier Safety Mode.
|
|
49
|
+
outcomes: Detailed per-test outcome records.
|
|
50
|
+
duration_seconds: Total walltime execution duration in seconds.
|
|
51
|
+
coverage_data: Optional serialized coverage data mapping.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
55
|
+
|
|
56
|
+
coverage_data: dict[str, Any] | None = Field(
|
|
57
|
+
default=None, description="Serialized coverage line mappings"
|
|
58
|
+
)
|
|
59
|
+
duration_seconds: float = Field(
|
|
60
|
+
default=0.0, ge=0.0, description="Slice walltime in seconds"
|
|
61
|
+
)
|
|
62
|
+
is_free_tier: bool = Field(
|
|
63
|
+
default=False, description="Executed under zero-cost Free-Tier"
|
|
64
|
+
)
|
|
65
|
+
outcomes: list[TestOutcomeItem] = Field(
|
|
66
|
+
default_factory=list, description="Per-test outcome records"
|
|
67
|
+
)
|
|
68
|
+
provider: str | None = Field(
|
|
69
|
+
default=None, description="Host cloud service provider or pool"
|
|
70
|
+
)
|
|
71
|
+
slice_id: str = Field(description="Slice identifier")
|
|
72
|
+
slice_index: int = Field(ge=0, description="Slice index")
|
|
73
|
+
worker_id: str | None = Field(
|
|
74
|
+
default=None, description="Executing worker identifier"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def passed_count(self) -> int:
|
|
79
|
+
"""Count of passed tests."""
|
|
80
|
+
return sum(1 for o in self.outcomes if o.outcome == "passed")
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def failed_count(self) -> int:
|
|
84
|
+
"""Count of failed tests."""
|
|
85
|
+
return sum(1 for o in self.outcomes if o.outcome == "failed")
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def skipped_count(self) -> int:
|
|
89
|
+
"""Count of skipped tests."""
|
|
90
|
+
return sum(1 for o in self.outcomes if o.outcome == "skipped")
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_success(self) -> bool:
|
|
94
|
+
"""Check if all tests passed or were skipped."""
|
|
95
|
+
return self.failed_count == 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"SliceOutcomeReport",
|
|
100
|
+
"TestOutcomeItem",
|
|
101
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Domain models for distributed test item representation and slicing specifications.
|
|
2
|
+
|
|
3
|
+
Notes/Architectural Intent:
|
|
4
|
+
Captures individual collected test items, partitioned execution slices,
|
|
5
|
+
and distributed test session metadata without external framework dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestItemInfo(BaseModel):
|
|
12
|
+
"""Metadata describing a single collected pytest item.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
nodeid: Pytest node identifier (e.g., 'tests/unit/test_app.py::test_init').
|
|
16
|
+
file_path: Relative filesystem path to the test module.
|
|
17
|
+
line_number: Optional line number where the test is defined.
|
|
18
|
+
markers: List of pytest marker tags.
|
|
19
|
+
estimated_duration: Estimated execution time in seconds.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__test__ = False
|
|
23
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
24
|
+
|
|
25
|
+
estimated_duration: float = Field(
|
|
26
|
+
default=1.0, ge=0.0, description="Estimated duration in seconds"
|
|
27
|
+
)
|
|
28
|
+
file_path: str = Field(description="Relative path to test module")
|
|
29
|
+
line_number: int | None = Field(
|
|
30
|
+
default=None, description="Test definition line number"
|
|
31
|
+
)
|
|
32
|
+
markers: list[str] = Field(default_factory=list, description="Pytest marker tags")
|
|
33
|
+
nodeid: str = Field(description="Unique pytest test node ID")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestSliceSpec(BaseModel):
|
|
37
|
+
"""Specification of an execution partition assigned to a cluster worker.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
slice_id: Unique identifier for the slice (e.g., 'slice-0').
|
|
41
|
+
slice_index: Zero-based index of this slice.
|
|
42
|
+
total_slices: Total count of slices in the partitioned run.
|
|
43
|
+
test_nodeids: Ordered list of test nodeids to execute in this slice.
|
|
44
|
+
total_estimated_duration: Sum of estimated test durations in seconds.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
__test__ = False
|
|
48
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
49
|
+
|
|
50
|
+
slice_id: str = Field(description="Slice identifier")
|
|
51
|
+
slice_index: int = Field(ge=0, description="Slice zero-based index")
|
|
52
|
+
test_nodeids: list[str] = Field(
|
|
53
|
+
default_factory=list, description="Assigned test node IDs"
|
|
54
|
+
)
|
|
55
|
+
total_estimated_duration: float = Field(
|
|
56
|
+
default=0.0, ge=0.0, description="Summed estimated duration"
|
|
57
|
+
)
|
|
58
|
+
total_slices: int = Field(ge=1, description="Total number of slices")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestRunSession(BaseModel):
|
|
62
|
+
"""State and topology for a distributed pytest execution run.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
session_id: Unique session identifier.
|
|
66
|
+
total_items: Total count of collected tests across the suite.
|
|
67
|
+
total_slices: Count of parallel slices generated.
|
|
68
|
+
slices: Sequence of partitioned TestSliceSpec instances.
|
|
69
|
+
server_url: Target Hexaqueue cluster controller API URL.
|
|
70
|
+
pool: Target compute pool for runner placement.
|
|
71
|
+
free_tier: Whether execution is governed under zero-cost Free-Tier Safety Mode.
|
|
72
|
+
container: Whether to execute slices inside isolated OCI container sandboxes.
|
|
73
|
+
container_image: Optional container image tag for worker runtime.
|
|
74
|
+
coverage_sources: Sequence of source packages or paths for coverage collection.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
__test__ = False
|
|
78
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
79
|
+
|
|
80
|
+
container: bool = Field(
|
|
81
|
+
default=False, description="Execute slices inside container worker runtime"
|
|
82
|
+
)
|
|
83
|
+
container_image: str | None = Field(
|
|
84
|
+
default=None, description="Optional container image for worker runtime"
|
|
85
|
+
)
|
|
86
|
+
coverage_sources: list[str] = Field(
|
|
87
|
+
default_factory=list,
|
|
88
|
+
description="Target packages or paths for coverage collection",
|
|
89
|
+
)
|
|
90
|
+
free_tier: bool = Field(
|
|
91
|
+
default=False, description="Enforce zero-cost Free-Tier limits"
|
|
92
|
+
)
|
|
93
|
+
pool: str = Field(default="default", description="Target instance pool")
|
|
94
|
+
server_url: str = Field(
|
|
95
|
+
default="http://127.0.0.1:8000", description="Hexaqueue controller URL"
|
|
96
|
+
)
|
|
97
|
+
session_id: str = Field(description="Unique test session UUID")
|
|
98
|
+
slices: list[TestSliceSpec] = Field(
|
|
99
|
+
default_factory=list, description="Partitioned test slices"
|
|
100
|
+
)
|
|
101
|
+
total_items: int = Field(ge=0, description="Total collected tests")
|
|
102
|
+
total_slices: int = Field(ge=1, description="Number of slices")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = [
|
|
106
|
+
"TestItemInfo",
|
|
107
|
+
"TestRunSession",
|
|
108
|
+
"TestSliceSpec",
|
|
109
|
+
]
|