Skip to content

Add support for Python 3.13 #103

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

Merged
merged 2 commits into from
Oct 16, 2024
Merged
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
@@ -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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
]
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

@@ -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.
@@ -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.
@@ -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(
@@ -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]":
54 changes: 53 additions & 1 deletion tests/test_algorithms.py
Original file line number Diff line number Diff line change
@@ -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

@@ -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