Skip to content
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

Add support for Python 3.13 #103

Merged
merged 2 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/python-poetry-env
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- Support for Python 3.13.

## [0.9.0] - 2024-06-19
### Changed
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
]
Expand Down
182 changes: 104 additions & 78 deletions src/pytest_split/algorithms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import enum
import functools
import heapq
from abc import ABC, abstractmethod
from operator import itemgetter
from typing import TYPE_CHECKING, NamedTuple

Expand All @@ -16,9 +16,25 @@ class TestGroup(NamedTuple):
duration: float


def least_duration(
splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]"
) -> "List[TestGroup]":
class AlgorithmBase(ABC):
"""Abstract base class for the algorithm implementations."""

@abstractmethod
def __call__(
self, splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]"
) -> "List[TestGroup]":
pass

def __hash__(self) -> int:
return hash(self.__class__.__name__)

def __eq__(self, other: object) -> bool:
if not isinstance(other, AlgorithmBase):
return NotImplemented
return self.__class__.__name__ == other.__class__.__name__


class LeastDurationAlgorithm(AlgorithmBase):
"""
Split tests into groups by runtime.
It walks the test items, starting with the test with largest duration.
Expand All @@ -34,60 +50,65 @@ def least_duration(
:return:
List of groups
"""
items_with_durations = _get_items_with_durations(items, durations)

# add index of item in list
items_with_durations_indexed = [
(*tup, i) for i, tup in enumerate(items_with_durations)
]
def __call__(
self, splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]"
) -> "List[TestGroup]":
items_with_durations = _get_items_with_durations(items, durations)

# Sort by name to ensure it's always the same order
items_with_durations_indexed = sorted(
items_with_durations_indexed, key=lambda tup: str(tup[0])
)

# sort in ascending order
sorted_items_with_durations = sorted(
items_with_durations_indexed, key=lambda tup: tup[1], reverse=True
)

selected: List[List[Tuple[nodes.Item, int]]] = [[] for _ in range(splits)]
deselected: List[List[nodes.Item]] = [[] for _ in range(splits)]
duration: List[float] = [0 for _ 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, item_duration, original_index in sorted_items_with_durations:
# 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, original_index))
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))

groups = []
for i in range(splits):
# sort the items by their original index to maintain relative ordering
# we don't care about the order of deselected items
s = [
item for item, original_index in sorted(selected[i], key=lambda tup: tup[1])
# add index of item in list
items_with_durations_indexed = [
(*tup, i) for i, tup in enumerate(items_with_durations)
]
group = TestGroup(selected=s, deselected=deselected[i], duration=duration[i])
groups.append(group)
return groups


def duration_based_chunks(
splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]"
) -> "List[TestGroup]":
# Sort by name to ensure it's always the same order
items_with_durations_indexed = sorted(
items_with_durations_indexed, key=lambda tup: str(tup[0])
)

# sort in ascending order
sorted_items_with_durations = sorted(
items_with_durations_indexed, key=lambda tup: tup[1], reverse=True
)

selected: List[List[Tuple[nodes.Item, int]]] = [[] for _ in range(splits)]
deselected: List[List[nodes.Item]] = [[] for _ in range(splits)]
duration: List[float] = [0 for _ 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, item_duration, original_index in sorted_items_with_durations:
# 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, original_index))
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))

groups = []
for i in range(splits):
# sort the items by their original index to maintain relative ordering
# we don't care about the order of deselected items
s = [
item
for item, original_index in sorted(selected[i], key=lambda tup: tup[1])
]
group = TestGroup(
selected=s, deselected=deselected[i], duration=duration[i]
)
groups.append(group)
return groups


class DurationBasedChunksAlgorithm(AlgorithmBase):
"""
Split tests into groups by runtime.
Ensures tests are split into non-overlapping groups.
Expand All @@ -99,28 +120,34 @@ def duration_based_chunks(
:param durations: Our cached test runtimes. Assumes contains timings only of relevant tests
:return: List of TestGroup
"""
items_with_durations = _get_items_with_durations(items, durations)
time_per_group = sum(map(itemgetter(1), items_with_durations)) / 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, item_duration in items_with_durations:
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] += item_duration

return [
TestGroup(selected=selected[i], deselected=deselected[i], duration=duration[i])
for i in range(splits)
]
def __call__(
self, splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]"
) -> "List[TestGroup]":
items_with_durations = _get_items_with_durations(items, durations)
time_per_group = sum(map(itemgetter(1), items_with_durations)) / 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, item_duration in items_with_durations:
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] += item_duration

return [
TestGroup(
selected=selected[i], deselected=deselected[i], duration=duration[i]
)
for i in range(splits)
]


def _get_items_with_durations(
Expand Down Expand Up @@ -153,9 +180,8 @@ def _remove_irrelevant_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)
duration_based_chunks = DurationBasedChunksAlgorithm()
least_duration = LeastDurationAlgorithm()

@staticmethod
def names() -> "List[str]":
Expand Down
54 changes: 53 additions & 1 deletion tests/test_algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@

from _pytest.nodes import Item

from pytest_split.algorithms import Algorithms
from pytest_split.algorithms import (
AlgorithmBase,
Algorithms,
)

item = namedtuple("item", "nodeid") # noqa: PYI024

Expand Down Expand Up @@ -132,3 +135,52 @@ def test__split_tests_same_set_regardless_of_order(self):
if not selected_each[i]:
selected_each[i] = set(group.selected)
assert selected_each[i] == set(group.selected)

def test__algorithms_members_derived_correctly(self):
for a in Algorithms.names():
assert issubclass(Algorithms[a].value.__class__, AlgorithmBase)


class MyAlgorithm(AlgorithmBase):
def __call__(self, a, b, c):
"""no-op"""


class MyOtherAlgorithm(AlgorithmBase):
def __call__(self, a, b, c):
"""no-op"""


class TestAbstractAlgorithm:
def test__hash__returns_correct_result(self):
algo = MyAlgorithm()
assert algo.__hash__() == hash(algo.__class__.__name__)

def test__hash__returns_same_hash_for_same_class_instances(self):
algo1 = MyAlgorithm()
algo2 = MyAlgorithm()
assert algo1.__hash__() == algo2.__hash__()

def test__hash__returns_different_hash_for_different_classes(self):
algo1 = MyAlgorithm()
algo2 = MyOtherAlgorithm()
assert algo1.__hash__() != algo2.__hash__()

def test__eq__returns_true_for_same_instance(self):
algo = MyAlgorithm()
assert algo.__eq__(algo) is True

def test__eq__returns_false_for_different_instance(self):
algo1 = MyAlgorithm()
algo2 = MyOtherAlgorithm()
assert algo1.__eq__(algo2) is False

def test__eq__returns_true_for_same_algorithm_different_instance(self):
algo1 = MyAlgorithm()
algo2 = MyAlgorithm()
assert algo1.__eq__(algo2) is True

def test__eq__returns_false_for_non_algorithm_object(self):
algo = MyAlgorithm()
other = "not an algorithm"
assert algo.__eq__(other) is NotImplemented