pytest-split 0.9.0__py3-none-any.whl → 0.11.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.
@@ -1,24 +1,38 @@
1
1
  import enum
2
- import functools
3
2
  import heapq
3
+ from abc import ABC, abstractmethod
4
4
  from operator import itemgetter
5
5
  from typing import TYPE_CHECKING, NamedTuple
6
6
 
7
7
  if TYPE_CHECKING:
8
- from typing import Dict, List, Tuple
9
-
10
8
  from _pytest import nodes
11
9
 
12
10
 
13
11
  class TestGroup(NamedTuple):
14
- selected: "List[nodes.Item]"
15
- deselected: "List[nodes.Item]"
12
+ selected: "list[nodes.Item]"
13
+ deselected: "list[nodes.Item]"
16
14
  duration: float
17
15
 
18
16
 
19
- def least_duration(
20
- splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]"
21
- ) -> "List[TestGroup]":
17
+ class AlgorithmBase(ABC):
18
+ """Abstract base class for the algorithm implementations."""
19
+
20
+ @abstractmethod
21
+ def __call__(
22
+ self, splits: int, items: "list[nodes.Item]", durations: "dict[str, float]"
23
+ ) -> "list[TestGroup]":
24
+ pass
25
+
26
+ def __hash__(self) -> int:
27
+ return hash(self.__class__.__name__)
28
+
29
+ def __eq__(self, other: object) -> bool:
30
+ if not isinstance(other, AlgorithmBase):
31
+ return NotImplemented
32
+ return self.__class__.__name__ == other.__class__.__name__
33
+
34
+
35
+ class LeastDurationAlgorithm(AlgorithmBase):
22
36
  """
23
37
  Split tests into groups by runtime.
24
38
  It walks the test items, starting with the test with largest duration.
@@ -34,60 +48,65 @@ def least_duration(
34
48
  :return:
35
49
  List of groups
36
50
  """
37
- items_with_durations = _get_items_with_durations(items, durations)
38
51
 
39
- # add index of item in list
40
- items_with_durations_indexed = [
41
- (*tup, i) for i, tup in enumerate(items_with_durations)
42
- ]
52
+ def __call__(
53
+ self, splits: int, items: "list[nodes.Item]", durations: "dict[str, float]"
54
+ ) -> "list[TestGroup]":
55
+ items_with_durations = _get_items_with_durations(items, durations)
43
56
 
44
- # Sort by name to ensure it's always the same order
45
- items_with_durations_indexed = sorted(
46
- items_with_durations_indexed, key=lambda tup: str(tup[0])
47
- )
48
-
49
- # sort in ascending order
50
- sorted_items_with_durations = sorted(
51
- items_with_durations_indexed, key=lambda tup: tup[1], reverse=True
52
- )
53
-
54
- selected: List[List[Tuple[nodes.Item, int]]] = [[] for _ in range(splits)]
55
- deselected: List[List[nodes.Item]] = [[] for _ in range(splits)]
56
- duration: List[float] = [0 for _ in range(splits)]
57
-
58
- # create a heap of the form (summed_durations, group_index)
59
- heap: List[Tuple[float, int]] = [(0, i) for i in range(splits)]
60
- heapq.heapify(heap)
61
- for item, item_duration, original_index in sorted_items_with_durations:
62
- # get group with smallest sum
63
- summed_durations, group_idx = heapq.heappop(heap)
64
- new_group_durations = summed_durations + item_duration
65
-
66
- # store assignment
67
- selected[group_idx].append((item, original_index))
68
- duration[group_idx] = new_group_durations
69
- for i in range(splits):
70
- if i != group_idx:
71
- deselected[i].append(item)
72
-
73
- # store new duration - in case of ties it sorts by the group_idx
74
- heapq.heappush(heap, (new_group_durations, group_idx))
75
-
76
- groups = []
77
- for i in range(splits):
78
- # sort the items by their original index to maintain relative ordering
79
- # we don't care about the order of deselected items
80
- s = [
81
- item for item, original_index in sorted(selected[i], key=lambda tup: tup[1])
57
+ # add index of item in list
58
+ items_with_durations_indexed = [
59
+ (*tup, i) for i, tup in enumerate(items_with_durations)
82
60
  ]
83
- group = TestGroup(selected=s, deselected=deselected[i], duration=duration[i])
84
- groups.append(group)
85
- return groups
86
61
 
87
-
88
- def duration_based_chunks(
89
- splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]"
90
- ) -> "List[TestGroup]":
62
+ # Sort by name to ensure it's always the same order
63
+ items_with_durations_indexed = sorted(
64
+ items_with_durations_indexed, key=lambda tup: str(tup[0])
65
+ )
66
+
67
+ # sort in ascending order
68
+ sorted_items_with_durations = sorted(
69
+ items_with_durations_indexed, key=lambda tup: tup[1], reverse=True
70
+ )
71
+
72
+ selected: list[list[tuple[nodes.Item, int]]] = [[] for _ in range(splits)]
73
+ deselected: list[list[nodes.Item]] = [[] for _ in range(splits)]
74
+ duration: list[float] = [0 for _ in range(splits)]
75
+
76
+ # create a heap of the form (summed_durations, group_index)
77
+ heap: list[tuple[float, int]] = [(0, i) for i in range(splits)]
78
+ heapq.heapify(heap)
79
+ for item, item_duration, original_index in sorted_items_with_durations:
80
+ # get group with smallest sum
81
+ summed_durations, group_idx = heapq.heappop(heap)
82
+ new_group_durations = summed_durations + item_duration
83
+
84
+ # store assignment
85
+ selected[group_idx].append((item, original_index))
86
+ duration[group_idx] = new_group_durations
87
+ for i in range(splits):
88
+ if i != group_idx:
89
+ deselected[i].append(item)
90
+
91
+ # store new duration - in case of ties it sorts by the group_idx
92
+ heapq.heappush(heap, (new_group_durations, group_idx))
93
+
94
+ groups = []
95
+ for i in range(splits):
96
+ # sort the items by their original index to maintain relative ordering
97
+ # we don't care about the order of deselected items
98
+ s = [
99
+ item
100
+ for item, original_index in sorted(selected[i], key=lambda tup: tup[1])
101
+ ]
102
+ group = TestGroup(
103
+ selected=s, deselected=deselected[i], duration=duration[i]
104
+ )
105
+ groups.append(group)
106
+ return groups
107
+
108
+
109
+ class DurationBasedChunksAlgorithm(AlgorithmBase):
91
110
  """
92
111
  Split tests into groups by runtime.
93
112
  Ensures tests are split into non-overlapping groups.
@@ -99,33 +118,39 @@ def duration_based_chunks(
99
118
  :param durations: Our cached test runtimes. Assumes contains timings only of relevant tests
100
119
  :return: List of TestGroup
101
120
  """
102
- items_with_durations = _get_items_with_durations(items, durations)
103
- time_per_group = sum(map(itemgetter(1), items_with_durations)) / splits
104
-
105
- selected: List[List[nodes.Item]] = [[] for i in range(splits)]
106
- deselected: List[List[nodes.Item]] = [[] for i in range(splits)]
107
- duration: List[float] = [0 for i in range(splits)]
108
-
109
- group_idx = 0
110
- for item, item_duration in items_with_durations:
111
- if duration[group_idx] >= time_per_group:
112
- group_idx += 1
113
121
 
114
- selected[group_idx].append(item)
115
- for i in range(splits):
116
- if i != group_idx:
117
- deselected[i].append(item)
118
- duration[group_idx] += item_duration
119
-
120
- return [
121
- TestGroup(selected=selected[i], deselected=deselected[i], duration=duration[i])
122
- for i in range(splits)
123
- ]
122
+ def __call__(
123
+ self, splits: int, items: "list[nodes.Item]", durations: "dict[str, float]"
124
+ ) -> "list[TestGroup]":
125
+ items_with_durations = _get_items_with_durations(items, durations)
126
+ time_per_group = sum(map(itemgetter(1), items_with_durations)) / splits
127
+
128
+ selected: list[list[nodes.Item]] = [[] for i in range(splits)]
129
+ deselected: list[list[nodes.Item]] = [[] for i in range(splits)]
130
+ duration: list[float] = [0 for i in range(splits)]
131
+
132
+ group_idx = 0
133
+ for item, item_duration in items_with_durations:
134
+ if duration[group_idx] >= time_per_group:
135
+ group_idx += 1
136
+
137
+ selected[group_idx].append(item)
138
+ for i in range(splits):
139
+ if i != group_idx:
140
+ deselected[i].append(item)
141
+ duration[group_idx] += item_duration
142
+
143
+ return [
144
+ TestGroup(
145
+ selected=selected[i], deselected=deselected[i], duration=duration[i]
146
+ )
147
+ for i in range(splits)
148
+ ]
124
149
 
125
150
 
126
151
  def _get_items_with_durations(
127
- items: "List[nodes.Item]", durations: "Dict[str, float]"
128
- ) -> "List[Tuple[nodes.Item, float]]":
152
+ items: "list[nodes.Item]", durations: "dict[str, float]"
153
+ ) -> "list[tuple[nodes.Item, float]]":
129
154
  durations = _remove_irrelevant_durations(items, durations)
130
155
  avg_duration_per_test = _get_avg_duration_per_test(durations)
131
156
  items_with_durations = [
@@ -134,7 +159,7 @@ def _get_items_with_durations(
134
159
  return items_with_durations
135
160
 
136
161
 
137
- def _get_avg_duration_per_test(durations: "Dict[str, float]") -> float:
162
+ def _get_avg_duration_per_test(durations: "dict[str, float]") -> float:
138
163
  if durations:
139
164
  avg_duration_per_test = sum(durations.values()) / len(durations)
140
165
  else:
@@ -144,8 +169,8 @@ def _get_avg_duration_per_test(durations: "Dict[str, float]") -> float:
144
169
 
145
170
 
146
171
  def _remove_irrelevant_durations(
147
- items: "List[nodes.Item]", durations: "Dict[str, float]"
148
- ) -> "Dict[str, float]":
172
+ items: "list[nodes.Item]", durations: "dict[str, float]"
173
+ ) -> "dict[str, float]":
149
174
  # Filtering down durations to relevant ones ensures the avg isn't skewed by irrelevant data
150
175
  test_ids = [item.nodeid for item in items]
151
176
  durations = {name: durations[name] for name in test_ids if name in durations}
@@ -153,10 +178,9 @@ def _remove_irrelevant_durations(
153
178
 
154
179
 
155
180
  class Algorithms(enum.Enum):
156
- # values have to wrapped inside functools to avoid them being considered method definitions
157
- duration_based_chunks = functools.partial(duration_based_chunks)
158
- least_duration = functools.partial(least_duration)
181
+ duration_based_chunks = DurationBasedChunksAlgorithm()
182
+ least_duration = LeastDurationAlgorithm()
159
183
 
160
184
  @staticmethod
161
- def names() -> "List[str]":
185
+ def names() -> "list[str]":
162
186
  return [x.name for x in Algorithms]
pytest_split/cli.py CHANGED
@@ -1,9 +1,5 @@
1
1
  import argparse
2
2
  import json
3
- from typing import TYPE_CHECKING
4
-
5
- if TYPE_CHECKING:
6
- from typing import Dict
7
3
 
8
4
 
9
5
  def list_slowest_tests() -> None:
@@ -28,7 +24,7 @@ def list_slowest_tests() -> None:
28
24
  return _list_slowest_tests(json.load(args.durations_path), args.count)
29
25
 
30
26
 
31
- def _list_slowest_tests(durations: "Dict[str, float]", count: int) -> None:
27
+ def _list_slowest_tests(durations: "dict[str, float]", count: int) -> None:
32
28
  slowest_tests = tuple(
33
29
  sorted(durations.items(), key=lambda item: item[1], reverse=True)
34
30
  )[:count]
@@ -1,8 +1,6 @@
1
1
  from typing import TYPE_CHECKING
2
2
 
3
3
  if TYPE_CHECKING:
4
- from typing import List
5
-
6
4
  from pytest_split.algorithms import TestGroup
7
5
 
8
6
 
@@ -45,8 +43,8 @@ def ensure_ipynb_compatibility(group: "TestGroup", items: list) -> None: # type
45
43
 
46
44
 
47
45
  def _find_sibiling_ipynb_cells(
48
- ipynb_node_id: str, item_node_ids: "List[str]"
49
- ) -> "List[str]":
46
+ ipynb_node_id: str, item_node_ids: "list[str]"
47
+ ) -> "list[str]":
50
48
  """
51
49
  Returns all sibling IPyNb cells given an IPyNb cell nodeid.
52
50
  """
pytest_split/plugin.py CHANGED
@@ -10,8 +10,6 @@ from pytest_split import algorithms
10
10
  from pytest_split.ipynb_compatibility import ensure_ipynb_compatibility
11
11
 
12
12
  if TYPE_CHECKING:
13
- from typing import Dict, List, Optional, Union
14
-
15
13
  from _pytest import nodes
16
14
  from _pytest.config import Config
17
15
  from _pytest.config.argparsing import Parser
@@ -77,7 +75,7 @@ def pytest_addoption(parser: "Parser") -> None:
77
75
 
78
76
 
79
77
  @pytest.hookimpl(tryfirst=True)
80
- def pytest_cmdline_main(config: "Config") -> "Optional[Union[int, ExitCode]]":
78
+ def pytest_cmdline_main(config: "Config") -> "int | ExitCode | None":
81
79
  """
82
80
  Validate options.
83
81
  """
@@ -153,7 +151,7 @@ class PytestSplitPlugin(Base):
153
151
 
154
152
  @hookimpl(trylast=True)
155
153
  def pytest_collection_modifyitems(
156
- self, config: "Config", items: "List[nodes.Item]"
154
+ self, config: "Config", items: "list[nodes.Item]"
157
155
  ) -> None:
158
156
  """
159
157
  Collect and select the tests we want to run, and deselect the rest.
@@ -193,7 +191,7 @@ class PytestSplitCachePlugin(Base):
193
191
  https://github.com/pytest-dev/pytest/blob/main/src/_pytest/main.py#L308
194
192
  """
195
193
  terminal_reporter = self.config.pluginmanager.get_plugin("terminalreporter")
196
- test_durations: Dict[str, float] = {}
194
+ test_durations: dict[str, float] = {}
197
195
 
198
196
  for test_reports in terminal_reporter.stats.values(): # type: ignore[union-attr]
199
197
  for test_report in test_reports:
@@ -1,28 +1,29 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: pytest-split
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: Pytest plugin which splits the test suite to equally sized sub suites based on test execution time.
5
- Home-page: https://jerry-git.github.io/pytest-split
6
5
  License: MIT
6
+ License-File: LICENSE
7
7
  Keywords: pytest,plugin,split,tests
8
8
  Author: Jerry Pussinen
9
9
  Author-email: jerry.pussinen@gmail.com
10
- Requires-Python: >=3.8.1,<4.0
10
+ Requires-Python: >=3.10,<4.0
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: MIT License
14
14
  Classifier: Operating System :: OS Independent
15
15
  Classifier: Programming Language :: Python
16
16
  Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.9
18
17
  Classifier: Programming Language :: Python :: 3.10
19
18
  Classifier: Programming Language :: Python :: 3.11
20
19
  Classifier: Programming Language :: Python :: 3.12
21
- Classifier: Programming Language :: Python :: 3.8
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
23
  Classifier: Typing :: Typed
24
- Requires-Dist: pytest (>=5,<9)
24
+ Requires-Dist: pytest (>=5,<10)
25
25
  Project-URL: Documentation, https://jerry-git.github.io/pytest-split
26
+ Project-URL: Homepage, https://jerry-git.github.io/pytest-split
26
27
  Project-URL: Repository, https://github.com/jerry-git/pytest-split
27
28
  Description-Content-Type: text/markdown
28
29
 
@@ -124,7 +125,7 @@ The `least_duration` algorithm walks the list of tests and assigns each test to
124
125
  * Clone this repository
125
126
  * Requirements:
126
127
  * [Poetry](https://python-poetry.org/)
127
- * Python 3.8+
128
+ * Python 3.10+
128
129
  * Create a virtual environment and install the dependencies
129
130
 
130
131
  ```sh
@@ -0,0 +1,11 @@
1
+ pytest_split/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pytest_split/algorithms.py,sha256=Ry1kbD_5vJgkUR4ubhmRPYanMbdxUh-Yym8vZpvn1Lk,6931
3
+ pytest_split/cli.py,sha256=AAQMPzLxDv0xoxrzg0LHJ3nTdjYxUc9UKoseNJdV8Ek,931
4
+ pytest_split/ipynb_compatibility.py,sha256=IEq56DJUVOYnHnqZmqJHVITJn8vZfVQKRwGPIxSU1JU,2075
5
+ pytest_split/plugin.py,sha256=ank0PKNwtoDGV3aj91FaJ8rZzqsfLu70WSvL3fpmWgM,7796
6
+ pytest_split/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pytest_split-0.11.0.dist-info/METADATA,sha256=p_eg9FiwZKoM1DFrUeadGEdNWGAFmaCvewkO2XbPB_Y,9518
8
+ pytest_split-0.11.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
9
+ pytest_split-0.11.0.dist-info/entry_points.txt,sha256=TQBEnYSUICZtzsc1CUyiYrOHpEll6HBDzIFvjJyo9F8,114
10
+ pytest_split-0.11.0.dist-info/licenses/LICENSE,sha256=mMm6KABGgOUinR1qBjELBaTHOU91sKkaWnprZ0RBilU,1058
11
+ pytest_split-0.11.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.9.0
2
+ Generator: poetry-core 2.3.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,11 +0,0 @@
1
- pytest_split/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- pytest_split/algorithms.py,sha256=4rNNyPzZskHgorFsGId_XHi2MzjkyZ1Lj8_TZD4B60k,6112
3
- pytest_split/cli.py,sha256=_Cc3h5jwOJyX7NZT4yil_ymG0g6Rw0257gh_63VoqzE,1011
4
- pytest_split/ipynb_compatibility.py,sha256=zBul7z3mB3UgM03IppqFpTZTdb2Vj1NrqxRC0kAgrCw,2104
5
- pytest_split/plugin.py,sha256=z3dTS3rK0wI2bj62Yw2O3y8120UfMiWnc6CJJyig-0k,7857
6
- pytest_split/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- pytest_split-0.9.0.dist-info/LICENSE,sha256=mMm6KABGgOUinR1qBjELBaTHOU91sKkaWnprZ0RBilU,1058
8
- pytest_split-0.9.0.dist-info/METADATA,sha256=IieeBEBthWLh2UzL7NkPRJZp0zoP-mIXjparUiVv-gQ,9480
9
- pytest_split-0.9.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
10
- pytest_split-0.9.0.dist-info/entry_points.txt,sha256=TQBEnYSUICZtzsc1CUyiYrOHpEll6HBDzIFvjJyo9F8,114
11
- pytest_split-0.9.0.dist-info/RECORD,,