Skip to content

Commit 6483851

Browse files
authored
allow targeted stealing (#9)
* allow targeted stealing * bump to 0.4.0
1 parent 4150290 commit 6483851

File tree

5 files changed

+166
-29
lines changed

5 files changed

+166
-29
lines changed

README.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,12 @@ jobs:
4646
4747
Pytest-cdist comes with several CLI and pytest-ini options:
4848
49-
| CLI | Ini | Allowed values | Default |
50-
|-------------------------|-----------------------|-------------------------------|---------|
51-
| `--cdist-justify-items` | `cdist-justify-items` | `none`, `file`, `scope` | `none` |
52-
| `--cdist-group-steal` | `--cdist-group-steal` | `<group number>:<percentage>` | - |
53-
| `--cdist-report` | - | - | false |
54-
| `--cdist-report-dir` | `cdist-report-dir` | | `.` |
49+
| CLI | Ini | Allowed values | Default |
50+
|-------------------------|-----------------------|------------------------------------------------------------------------------|---------|
51+
| `--cdist-justify-items` | `cdist-justify-items` | `none`, `file`, `scope` | `none` |
52+
| `--cdist-group-steal` | `--cdist-group-steal` | `<target group>:<percentage>` / `<target group>:<percentage>:<source group>` | - |
53+
| `--cdist-report` | - | - | false |
54+
| `--cdist-report-dir` | `cdist-report-dir` | | `.` |
5555

5656

5757
### Controlling how items are split up
@@ -96,6 +96,15 @@ cdist-group-steal=2:30
9696
pytest --cdist-group=1/2 --cdist-group-steal=2:30
9797
```
9898

99+
It is also possible to redistribute items between two specific groups, by specifying
100+
both as source and a target group. The following configuration would assign 50% of the
101+
items in group 1 to group 2:
102+
103+
```bash
104+
pytest --cdist-group=1/3 --cdist-group-steal=1:50:2
105+
```
106+
107+
99108
### With pytest-xdist
100109

101110
When running under pytest-xdist, pytest-cdist will honour tests marked with

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pytest-cdist"
3-
version = "0.3.1"
3+
version = "0.4.0"
44
description = "A pytest plugin to split your test suite into multiple parts"
55
authors = [
66
{ name = "Janek Nouvertné", email = "provinzkraut@posteo.de" },

pytest_cdist/plugin.py

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import os
77
import pathlib
8+
import re
89
from typing import TypeVar, Literal, TYPE_CHECKING
910

1011
import pytest
@@ -42,24 +43,76 @@ def _get_item_file(item: pytest.Item) -> str:
4243
return item.nodeid.split("::", 1)[0]
4344

4445

46+
@dataclasses.dataclass(frozen=True)
47+
class GroupStealOpt:
48+
source_group: int | None
49+
target_group: int
50+
amount: int
51+
52+
def __str__(self) -> str:
53+
out = f"g{self.target_group}:{self.amount}"
54+
if self.source_group:
55+
out += f":g{self.source_group}"
56+
return out
57+
58+
59+
def _distribute(
60+
groups: list[list[pytest.Item]],
61+
from_: int,
62+
to: int,
63+
amount: int,
64+
) -> None:
65+
items = groups[from_]
66+
num_items_to_move = max(0, min(len(items), (len(items) * amount) // 100))
67+
items_to_move = items[:num_items_to_move]
68+
groups[to].extend(items_to_move)
69+
groups[from_] = items[num_items_to_move:]
70+
71+
4572
def _distribute_with_bias(
46-
groups: list[list[pytest.Item]], target: int, bias: int
73+
groups: list[list[pytest.Item]],
74+
group_steal: GroupStealOpt,
4775
) -> list[list[pytest.Item]]:
48-
for i, lst in enumerate(groups):
49-
if i != target:
50-
num_items_to_move = max(0, min(len(lst), (len(lst) * bias) // 100))
51-
items_to_move = lst[:num_items_to_move]
52-
groups[target].extend(items_to_move)
53-
groups[i] = lst[num_items_to_move:]
76+
source_groups = (
77+
[group_steal.source_group]
78+
if group_steal.source_group is not None
79+
else range(len(groups))
80+
)
81+
for source_group in source_groups:
82+
if source_group != group_steal.target_group:
83+
_distribute(
84+
groups,
85+
from_=source_group,
86+
to=group_steal.target_group,
87+
amount=group_steal.amount,
88+
)
5489

5590
return groups
5691

5792

58-
def _get_group_steal_opt(opt: str | None) -> tuple[int, int] | None:
93+
def _get_group_steal_opt(opt: str | None) -> list[GroupStealOpt]:
5994
if opt is None:
60-
return None
61-
target_group, amount_to_steal = opt.split(":")
62-
return int(target_group) - 1, int(amount_to_steal)
95+
return []
96+
97+
opts = []
98+
for group_opt in opt.split(","):
99+
match = re.match(r"g?(\d+):(\d+)(?::g?(\d+))?", group_opt.strip())
100+
if match is None:
101+
raise ValueError(f"Invalid group steal option: {group_opt!r}")
102+
source_group: int | None = None
103+
target_group = int(match.group(1)) - 1
104+
amount_to_steal = int(match.group(2))
105+
if source_group_match := match.group(3):
106+
source_group = int(source_group_match) - 1
107+
108+
opts.append(
109+
GroupStealOpt(
110+
source_group=source_group,
111+
target_group=target_group,
112+
amount=amount_to_steal,
113+
)
114+
)
115+
return opts
63116

64117

65118
def _justify_items(
@@ -113,12 +166,12 @@ def _justify_xdist_groups(groups: list[list[pytest.Item]]) -> list[list[pytest.I
113166
return groups
114167

115168

116-
@dataclasses.dataclass
169+
@dataclasses.dataclass(kw_only=True)
117170
class CdistConfig:
118171
current_group: int
119172
total_groups: int
120173
justify_items_strategy: JustifyItemsStrategy = "none"
121-
group_steal: tuple[int, int] | None = None
174+
group_steal: list[GroupStealOpt]
122175
write_report: bool = False
123176
report_dir: pathlib.Path = pathlib.Path(".")
124177

@@ -132,9 +185,8 @@ def cli_options(self, config: pytest.Config) -> str:
132185
opts.append(f"--cdist-justify-items={self.justify_items_strategy}")
133186

134187
if self.group_steal and "cdist-group-steal" not in config.inicfg:
135-
opts.append(
136-
f"--cdist-group-steal={self.group_steal[0] + 1}:{self.group_steal[1]}"
137-
)
188+
steal_opt = ",".join(map(str, self.group_steal))
189+
opts.append(f"--cdist-group-steal={steal_opt}")
138190

139191
if self.write_report:
140192
opts.append("--cdist-report")
@@ -202,7 +254,7 @@ def pytest_addoption(parser: pytest.Parser) -> None:
202254
action="store",
203255
default=None,
204256
help="make a group steal a percentage of items from other groups. '1:30' would "
205-
"make group 1 steal 30%% of items from all other groups)",
257+
"make group 1 steal 30% of items from all other groups",
206258
)
207259

208260
parser.addini("cdist-justify-items", help="justify items strategy", default="none")
@@ -238,12 +290,10 @@ def pytest_collection_modifyitems(
238290

239291
groups = _partition_list(items, cdist_config.total_groups)
240292

241-
if cdist_config.group_steal is not None:
242-
target_group, amount_to_steal = cdist_config.group_steal
293+
for group_steal in cdist_config.group_steal:
243294
groups = _distribute_with_bias(
244295
groups,
245-
target=target_group,
246-
bias=amount_to_steal,
296+
group_steal=group_steal,
247297
)
248298

249299
if cdist_config.justify_items_strategy != "none":

tests/test_plugin.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,84 @@ def test_four():
209209
result.assert_outcomes(passed=3, deselected=1)
210210

211211

212+
def test_steal_with_target(pytester: pytest.Pytester) -> None:
213+
pytester.makepyfile("""
214+
def test_one():
215+
assert False
216+
217+
def test_two():
218+
assert True
219+
220+
def test_three():
221+
assert True
222+
223+
def test_four():
224+
assert True
225+
""")
226+
227+
# natural distribution would be
228+
# 1: test_one, test_two
229+
# 2: test_three
230+
# 3: test_four
231+
# telling group 3 to steal 50% of group 1 should result in
232+
# 1: test_two
233+
# 2: test_three
234+
# 3: test_four, test_one
235+
cli_opt = "--cdist-group-steal=g3:50:g1"
236+
result = pytester.runpytest_inprocess("--cdist-group=1/3", cli_opt)
237+
result.assert_outcomes(passed=1, deselected=3)
238+
239+
result = pytester.runpytest_inprocess("--cdist-group=2/3", cli_opt)
240+
result.assert_outcomes(passed=1, deselected=3)
241+
242+
result = pytester.runpytest_inprocess("--cdist-group=3/3", cli_opt)
243+
result.assert_outcomes(passed=1, failed=1, deselected=2)
244+
245+
246+
def test_steal_multiple_target(pytester: pytest.Pytester) -> None:
247+
pytester.makepyfile("""
248+
def test_one():
249+
assert True
250+
251+
def test_two():
252+
assert True
253+
254+
def test_three():
255+
assert True
256+
257+
def test_four():
258+
assert True
259+
260+
def test_five():
261+
assert True
262+
263+
def test_six():
264+
assert True
265+
""")
266+
267+
# natural distribution would be
268+
# 1: 2
269+
# 2: 2
270+
# 3: 2
271+
# first, we're telling group 2 to steal 50% of all other groups:
272+
# 1: 1
273+
# 2: 4
274+
# 3: 1
275+
# then, we're telling group 3 to steal 50% of group 2
276+
# 1: 1
277+
# 2: 2
278+
# 3: 3
279+
cli_opt = "--cdist-group-steal=g2:50,g3:50:g2"
280+
result = pytester.runpytest("--cdist-group=1/3", cli_opt)
281+
result.assert_outcomes(passed=1, deselected=5)
282+
283+
result = pytester.runpytest("--cdist-group=2/3", cli_opt)
284+
result.assert_outcomes(passed=2, deselected=4)
285+
286+
result = pytester.runpytest("--cdist-group=3/3", cli_opt)
287+
result.assert_outcomes(passed=3, deselected=3)
288+
289+
212290
def test_steal_with_justify(pytester: pytest.Pytester) -> None:
213291
pytester.makepyfile("""
214292
class TestFoo:

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)