-
Notifications
You must be signed in to change notification settings - Fork 38
Add splitting algorithm #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2afa561
9afe747
d4eaf55
5d1ffd8
aa09d63
456811a
3a5f273
e829189
1c7a76b
fd563d8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,119 @@ | ||
import enum | ||
import functools | ||
import heapq | ||
from typing import TYPE_CHECKING, NamedTuple | ||
|
||
if TYPE_CHECKING: | ||
from typing import Dict, List, Tuple | ||
|
||
from _pytest import nodes | ||
|
||
|
||
class TestGroup(NamedTuple): | ||
selected: "List[nodes.Item]" | ||
deselected: "List[nodes.Item]" | ||
duration: float | ||
|
||
|
||
def least_duration(splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]") -> "List[TestGroup]": | ||
""" | ||
Split tests into groups by runtime. | ||
Assigns the test with the largest runtime to the test with the smallest | ||
duration sum. | ||
|
||
:param splits: How many groups we're splitting in. | ||
:param items: Test items passed down by Pytest. | ||
:param durations: Our cached test runtimes. Assumes contains timings only of relevant tests | ||
:return: | ||
List of groups | ||
""" | ||
durations = _remove_irrelevant_durations(items, durations) | ||
avg_duration_per_test = _get_avg_duration_per_test(durations) | ||
|
||
selected: "List[List[nodes.Item]]" = [[] for i in range(splits)] | ||
deselected: "List[List[nodes.Item]]" = [[] for i in range(splits)] | ||
duration: "List[float]" = [0 for i in range(splits)] | ||
|
||
# create a heap of the form (summed_durations, group_index) | ||
heap: "List[Tuple[float, int]]" = [(0, i) for i in range(splits)] | ||
heapq.heapify(heap) | ||
for item in items: | ||
item_duration = durations.get(item.nodeid, avg_duration_per_test) | ||
|
||
# get group with smallest sum | ||
summed_durations, group_idx = heapq.heappop(heap) | ||
new_group_durations = summed_durations + item_duration | ||
|
||
# store assignment | ||
selected[group_idx].append(item) | ||
duration[group_idx] = new_group_durations | ||
for i in range(splits): | ||
if i != group_idx: | ||
deselected[i].append(item) | ||
|
||
# store new duration - in case of ties it sorts by the group_idx | ||
heapq.heappush(heap, (new_group_durations, group_idx)) | ||
|
||
return [TestGroup(selected=selected[i], deselected=deselected[i], duration=duration[i]) for i in range(splits)] | ||
|
||
|
||
def duration_based_chunks(splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]") -> "List[TestGroup]": | ||
""" | ||
Split tests into groups by runtime. | ||
Ensures tests are split into non-overlapping groups. | ||
The original list of test items is split into groups by finding boundary indices i_0, i_1, i_2 | ||
and creating group_1 = items[0:i_0], group_2 = items[i_0, i_1], group_3 = items[i_1, i_2], ... | ||
|
||
:param splits: How many groups we're splitting in. | ||
:param items: Test items passed down by Pytest. | ||
:param durations: Our cached test runtimes. Assumes contains timings only of relevant tests | ||
:return: List of TestGroup | ||
""" | ||
durations = _remove_irrelevant_durations(items, durations) | ||
avg_duration_per_test = _get_avg_duration_per_test(durations) | ||
|
||
tests_and_durations = {item: durations.get(item.nodeid, avg_duration_per_test) for item in items} | ||
time_per_group = sum(tests_and_durations.values()) / splits | ||
|
||
selected: "List[List[nodes.Item]]" = [[] for i in range(splits)] | ||
deselected: "List[List[nodes.Item]]" = [[] for i in range(splits)] | ||
duration: "List[float]" = [0 for i in range(splits)] | ||
|
||
group_idx = 0 | ||
for item in items: | ||
if duration[group_idx] >= time_per_group: | ||
group_idx += 1 | ||
|
||
selected[group_idx].append(item) | ||
for i in range(splits): | ||
if i != group_idx: | ||
deselected[i].append(item) | ||
duration[group_idx] += tests_and_durations.pop(item) | ||
|
||
return [TestGroup(selected=selected[i], deselected=deselected[i], duration=duration[i]) for i in range(splits)] | ||
|
||
|
||
def _get_avg_duration_per_test(durations: "Dict[str, float]") -> float: | ||
if durations: | ||
avg_duration_per_test = sum(durations.values()) / len(durations) | ||
else: | ||
# If there are no durations, give every test the same arbitrary value | ||
avg_duration_per_test = 1 | ||
return avg_duration_per_test | ||
|
||
|
||
def _remove_irrelevant_durations(items: "List[nodes.Item]", durations: "Dict[str, float]") -> "Dict[str, float]": | ||
# Filtering down durations to relevant ones ensures the avg isn't skewed by irrelevant data | ||
test_ids = [item.nodeid for item in items] | ||
durations = {name: durations[name] for name in test_ids if name in durations} | ||
return durations | ||
|
||
|
||
class Algorithms(enum.Enum): | ||
# values have to wrapped inside functools to avoid them being considered method definitions | ||
duration_based_chunks = functools.partial(duration_based_chunks) | ||
least_duration = functools.partial(least_duration) | ||
|
||
@staticmethod | ||
def names() -> "List[str]": | ||
return [x.name for x in Algorithms] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
from collections import namedtuple | ||
|
||
import pytest | ||
|
||
from pytest_split.algorithms import Algorithms | ||
|
||
item = namedtuple("item", "nodeid") | ||
|
||
|
||
class TestAlgorithms: | ||
@pytest.mark.parametrize("algo_name", Algorithms.names()) | ||
def test__split_test(self, algo_name): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's keep consistency, other test modules seem to use single There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is consistent in the sense that I use the format: Please let me know if you still prefer the change. |
||
durations = {"a": 1, "b": 1, "c": 1} | ||
items = [item(x) for x in durations.keys()] | ||
algo = Algorithms[algo_name].value | ||
first, second, third = algo(splits=3, items=items, durations=durations) | ||
|
||
# each split should have one test | ||
assert first.selected == [item("a")] | ||
assert first.deselected == [item("b"), item("c")] | ||
assert first.duration == 1 | ||
|
||
assert second.selected == [item("b")] | ||
assert second.deselected == [item("a"), item("c")] | ||
assert second.duration == 1 | ||
|
||
assert third.selected == [item("c")] | ||
assert third.deselected == [item("a"), item("b")] | ||
assert third.duration == 1 | ||
|
||
@pytest.mark.parametrize("algo_name", Algorithms.names()) | ||
def test__split_tests_handles_tests_in_durations_but_missing_from_items(self, algo_name): | ||
durations = {"a": 1, "b": 1} | ||
items = [item(x) for x in ["a"]] | ||
algo = Algorithms[algo_name].value | ||
splits = algo(splits=2, items=items, durations=durations) | ||
|
||
first, second = splits | ||
assert first.selected == [item("a")] | ||
assert second.selected == [] | ||
|
||
@pytest.mark.parametrize("algo_name", Algorithms.names()) | ||
def test__split_tests_handles_tests_with_missing_durations(self, algo_name): | ||
durations = {"a": 1} | ||
items = [item(x) for x in ["a", "b"]] | ||
algo = Algorithms[algo_name].value | ||
splits = algo(splits=2, items=items, durations=durations) | ||
|
||
first, second = splits | ||
assert first.selected == [item("a")] | ||
assert second.selected == [item("b")] | ||
|
||
@pytest.mark.parametrize("algo_name", Algorithms.names()) | ||
@pytest.mark.skip("current algorithm does handle this well") | ||
def test__split_test_handles_large_duration_at_end(self, algo_name): | ||
durations = {"a": 1, "b": 1, "c": 1, "d": 3} | ||
items = [item(x) for x in ["a", "b", "c", "d"]] | ||
algo = Algorithms[algo_name].value | ||
splits = algo(splits=2, items=items, durations=durations) | ||
|
||
first, second = splits | ||
assert first.selected == [item("d")] | ||
assert second.selected == [item(x) for x in ["a", "b", "c"]] | ||
|
||
@pytest.mark.parametrize( | ||
"algo_name, expected", | ||
[ | ||
("duration_based_chunks", [[item("a"), item("b")], [item("c"), item("d")]]), | ||
("least_duration", [[item("a"), item("c")], [item("b"), item("d")]]), | ||
], | ||
) | ||
def test__split_tests_calculates_avg_test_duration_only_on_present_tests(self, algo_name, expected): | ||
# If the algo includes test e's duration to calculate the averge then | ||
# a will be expected to take a long time, and so 'a' will become its | ||
# own group. Intended behaviour is that a gets estimated duration 1 and | ||
# this will create more balanced groups. | ||
durations = {"b": 1, "c": 1, "d": 1, "e": 10000} | ||
items = [item(x) for x in ["a", "b", "c", "d"]] | ||
algo = Algorithms[algo_name].value | ||
splits = algo(splits=2, items=items, durations=durations) | ||
|
||
first, second = splits | ||
expected_first, expected_second = expected | ||
assert first.selected == expected_first | ||
assert second.selected == expected_second |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it could be valuable to mention also in the
usage
section that one can specify the splitting algorithm via command line arg and also mention what is the default behaviourThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a small note about it.