diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 061a2e2..f2dbbdd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - uses: ./.github/actions/python-poetry-env diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c980a0..2e5146b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed - Migrated `poetry.dev-dependencies` to `poetry.group.dev.dependencies` +### Removed +- Support for Python 3.8 and 3.9 (end-of-life) + ## [0.10.0] - 2024-10-16 ### Added - Support for Python 3.13. diff --git a/README.md b/README.md index 11f8a00..fe40daa 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ The `least_duration` algorithm walks the list of tests and assigns each test to * Clone this repository * Requirements: * [Poetry](https://python-poetry.org/) - * Python 3.8+ + * Python 3.10+ * Create a virtual environment and install the dependencies ```sh diff --git a/poetry.lock b/poetry.lock index 1b54ccc..477f6b2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,22 +1,5 @@ # This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. -[[package]] -name = "astunparse" -version = "1.6.3" -description = "An AST unparser for Python" -optional = false -python-versions = "*" -groups = ["dev"] -markers = "python_version < \"3.9\"" -files = [ - {file = "astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8"}, - {file = "astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872"}, -] - -[package.dependencies] -six = ">=1.6.1,<2.0" -wheel = ">=0.23.0,<1.0" - [[package]] name = "babel" version = "2.15.0" @@ -29,9 +12,6 @@ files = [ {file = "babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413"}, ] -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] @@ -274,7 +254,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, @@ -365,7 +345,6 @@ files = [ ] [package.dependencies] -astunparse = {version = ">=1.6", markers = "python_version < \"3.9\""} colorama = ">=0.4" [[package]] @@ -457,9 +436,6 @@ files = [ {file = "Markdown-3.6.tar.gz", hash = "sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224"}, ] -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - [package.extras] docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] testing = ["coverage", "pyyaml"] @@ -562,7 +538,6 @@ files = [ click = ">=7.0" colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} jinja2 = ">=2.11.1" markdown = ">=3.3.6" markupsafe = ">=2.0.1" @@ -608,7 +583,6 @@ files = [ ] [package.dependencies] -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} mergedeep = ">=1.3.4" platformdirs = ">=2.2.0" pyyaml = ">=5.1" @@ -669,7 +643,6 @@ files = [ [package.dependencies] click = ">=7.0" -importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} Jinja2 = ">=2.11.1" Markdown = ">=3.3" MarkupSafe = ">=1.1" @@ -678,7 +651,6 @@ mkdocs-autorefs = ">=0.3.1" mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""} platformdirs = ">=2.2.0" pymdown-extensions = ">=6.3" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.10\""} [package.extras] crystal = ["mkdocstrings-crystal (>=0.3.4)"] @@ -984,19 +956,6 @@ gitpython = "*" pyyaml = "*" semver = "*" -[[package]] -name = "pytz" -version = "2024.1" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -groups = ["dev"] -markers = "python_version < \"3.9\"" -files = [ - {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, - {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, -] - [[package]] name = "pyyaml" version = "6.0.1" @@ -1258,7 +1217,7 @@ files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] -markers = {main = "python_version < \"3.11\"", dev = "python_full_version <= \"3.11.0a6\""} +markers = {main = "python_version == \"3.10\"", dev = "python_full_version <= \"3.11.0a6\""} [[package]] name = "typing-extensions" @@ -1356,22 +1315,6 @@ files = [ [package.extras] watchmedo = ["PyYAML (>=3.10)"] -[[package]] -name = "wheel" -version = "0.43.0" -description = "A built-package format for Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version < \"3.9\"" -files = [ - {file = "wheel-0.43.0-py3-none-any.whl", hash = "sha256:55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81"}, - {file = "wheel-0.43.0.tar.gz", hash = "sha256:465ef92c69fa5c5da2d1cf8ac40559a8c940886afcef87dcf14b9470862f1d85"}, -] - -[package.extras] -test = ["pytest (>=6.0.0)", "setuptools (>=65)"] - [[package]] name = "zipp" version = "3.19.2" @@ -1390,5 +1333,5 @@ test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.funct [metadata] lock-version = "2.1" -python-versions = ">=3.8.1, <4.0" -content-hash = "03c44ffe73f206d4a232792d6d481865d456ef414db96aaba03376330e4252b2" +python-versions = ">=3.10, <4.0" +content-hash = "b0ef625408ac5481b3f6e0850d6426392952969fdf62dca969986321f5bfbed1" diff --git a/pyproject.toml b/pyproject.toml index d3076ff..f69662d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,8 +18,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -34,7 +32,7 @@ packages = [{ include = 'pytest_split', from = 'src' }] [tool.poetry.dependencies] -python = ">=3.8.1, <4.0" +python = ">=3.10, <4.0" pytest = "^5 | ^6 | ^7 | ^8 | ^9" @@ -62,7 +60,7 @@ slowest-tests = "pytest_split.cli:list_slowest_tests" pytest-split = "pytest_split.plugin" [tool.black] -target-version = ["py37", "py38", "py39"] +target-version = ["py310", "py311", "py312", "py313", "py314"] include = '\.pyi?$' [tool.pytest.ini_options] @@ -94,7 +92,7 @@ disallow_untyped_calls = false [tool.ruff] -target-version = "py38" # The lowest supported version +target-version = "py310" # The lowest supported version [tool.ruff.lint] # By default, enable all the lint rules. diff --git a/src/pytest_split/algorithms.py b/src/pytest_split/algorithms.py index 8c47bd4..83223b3 100644 --- a/src/pytest_split/algorithms.py +++ b/src/pytest_split/algorithms.py @@ -5,14 +5,12 @@ 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]" + selected: "list[nodes.Item]" + deselected: "list[nodes.Item]" duration: float @@ -21,8 +19,8 @@ class AlgorithmBase(ABC): @abstractmethod def __call__( - self, splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]" - ) -> "List[TestGroup]": + self, splits: int, items: "list[nodes.Item]", durations: "dict[str, float]" + ) -> "list[TestGroup]": pass def __hash__(self) -> int: @@ -52,8 +50,8 @@ class LeastDurationAlgorithm(AlgorithmBase): """ def __call__( - self, splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]" - ) -> "List[TestGroup]": + self, splits: int, items: "list[nodes.Item]", durations: "dict[str, float]" + ) -> "list[TestGroup]": items_with_durations = _get_items_with_durations(items, durations) # add index of item in list @@ -71,12 +69,12 @@ def __call__( 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)] + 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)] + 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 @@ -122,14 +120,14 @@ class DurationBasedChunksAlgorithm(AlgorithmBase): """ def __call__( - self, splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]" - ) -> "List[TestGroup]": + 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)] + 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: @@ -151,8 +149,8 @@ def __call__( def _get_items_with_durations( - items: "List[nodes.Item]", durations: "Dict[str, float]" -) -> "List[Tuple[nodes.Item, float]]": + items: "list[nodes.Item]", durations: "dict[str, float]" +) -> "list[tuple[nodes.Item, float]]": durations = _remove_irrelevant_durations(items, durations) avg_duration_per_test = _get_avg_duration_per_test(durations) items_with_durations = [ @@ -161,7 +159,7 @@ def _get_items_with_durations( return items_with_durations -def _get_avg_duration_per_test(durations: "Dict[str, float]") -> float: +def _get_avg_duration_per_test(durations: "dict[str, float]") -> float: if durations: avg_duration_per_test = sum(durations.values()) / len(durations) else: @@ -171,8 +169,8 @@ def _get_avg_duration_per_test(durations: "Dict[str, float]") -> float: def _remove_irrelevant_durations( - items: "List[nodes.Item]", durations: "Dict[str, float]" -) -> "Dict[str, float]": + 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} @@ -184,5 +182,5 @@ class Algorithms(enum.Enum): least_duration = LeastDurationAlgorithm() @staticmethod - def names() -> "List[str]": + def names() -> "list[str]": return [x.name for x in Algorithms] diff --git a/src/pytest_split/cli.py b/src/pytest_split/cli.py index f1bab1b..801fe3a 100644 --- a/src/pytest_split/cli.py +++ b/src/pytest_split/cli.py @@ -1,9 +1,5 @@ import argparse import json -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Dict def list_slowest_tests() -> None: @@ -28,7 +24,7 @@ def list_slowest_tests() -> None: return _list_slowest_tests(json.load(args.durations_path), args.count) -def _list_slowest_tests(durations: "Dict[str, float]", count: int) -> None: +def _list_slowest_tests(durations: "dict[str, float]", count: int) -> None: slowest_tests = tuple( sorted(durations.items(), key=lambda item: item[1], reverse=True) )[:count] diff --git a/src/pytest_split/ipynb_compatibility.py b/src/pytest_split/ipynb_compatibility.py index 2c6ba8d..b107cb6 100644 --- a/src/pytest_split/ipynb_compatibility.py +++ b/src/pytest_split/ipynb_compatibility.py @@ -1,8 +1,6 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import List - from pytest_split.algorithms import TestGroup @@ -45,8 +43,8 @@ def ensure_ipynb_compatibility(group: "TestGroup", items: list) -> None: # type def _find_sibiling_ipynb_cells( - ipynb_node_id: str, item_node_ids: "List[str]" -) -> "List[str]": + ipynb_node_id: str, item_node_ids: "list[str]" +) -> "list[str]": """ Returns all sibling IPyNb cells given an IPyNb cell nodeid. """ diff --git a/src/pytest_split/plugin.py b/src/pytest_split/plugin.py index b4bee62..9140936 100644 --- a/src/pytest_split/plugin.py +++ b/src/pytest_split/plugin.py @@ -10,8 +10,6 @@ from pytest_split.ipynb_compatibility import ensure_ipynb_compatibility if TYPE_CHECKING: - from typing import Dict, List, Optional, Union - from _pytest import nodes from _pytest.config import Config from _pytest.config.argparsing import Parser @@ -77,7 +75,7 @@ def pytest_addoption(parser: "Parser") -> None: @pytest.hookimpl(tryfirst=True) -def pytest_cmdline_main(config: "Config") -> "Optional[Union[int, ExitCode]]": +def pytest_cmdline_main(config: "Config") -> "int | ExitCode | None": """ Validate options. """ @@ -153,7 +151,7 @@ def __init__(self, config: "Config"): @hookimpl(trylast=True) def pytest_collection_modifyitems( - self, config: "Config", items: "List[nodes.Item]" + self, config: "Config", items: "list[nodes.Item]" ) -> None: """ Collect and select the tests we want to run, and deselect the rest. @@ -193,7 +191,7 @@ def pytest_sessionfinish(self) -> None: https://github.com/pytest-dev/pytest/blob/main/src/_pytest/main.py#L308 """ terminal_reporter = self.config.pluginmanager.get_plugin("terminalreporter") - test_durations: Dict[str, float] = {} + test_durations: dict[str, float] = {} for test_reports in terminal_reporter.stats.values(): # type: ignore[union-attr] for test_report in test_reports: diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index b352db7..a02b6ed 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -5,8 +5,6 @@ import pytest if TYPE_CHECKING: - from typing import List, Set - from _pytest.nodes import Item from pytest_split.algorithms import ( @@ -128,7 +126,7 @@ def test__split_tests_same_set_regardless_of_order(self): items = [item(t) for t in tests] algo = Algorithms["least_duration"].value for n in (2, 3, 4): - selected_each: List[Set[Item]] = [set() for _ in range(n)] + selected_each: list[set[Item]] = [set() for _ in range(n)] for order in itertools.permutations(items): splits = algo(splits=n, items=order, durations=durations) for i, group in enumerate(splits): diff --git a/tests/test_cli.py b/tests/test_cli.py index 2de4fa7..6621ca0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,9 +19,10 @@ def durations_file(tmpdir): def test_slowest_tests(durations_file): - with patch( - "pytest_split.cli.argparse.ArgumentParser", autospec=True - ) as arg_parser, patch("sys.stdout", new_callable=StringIO): + with ( + patch("pytest_split.cli.argparse.ArgumentParser", autospec=True) as arg_parser, + patch("sys.stdout", new_callable=StringIO), + ): arg_parser().parse_args.return_value = argparse.Namespace( durations_path=durations_file, count=3 ) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index aaadab1..51b8577 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -306,7 +306,9 @@ def test_it_splits_with_other_collect_hooks(self, testdir, durations_path): for group in range(1, 3) ] - for result, expected_tests in zip(results, expected_tests_per_group): + for result, expected_tests in zip( + results, expected_tests_per_group, strict=False + ): result.assertoutcome(passed=len(expected_tests)) assert _passed_test_names(result) == expected_tests