diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1fee0ee --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,49 @@ +name: test + +on: + pull_request_target: + pull_request: + push: + branches: + - master + +jobs: + linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: 3.9 + - uses: actions/cache@v2 + with: + path: | + ~/.cache/pip + ~/.cache/pre-commit + key: ${{ runner.os }}-pip-2 + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + - run: python -m pip install pre-commit + - run: pre-commit run --all-files + test: + needs: linting + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ "3.6", "3.7", "3.8", "3.9", ] # "3.10.0-beta.1" + pytest-version: [ "4", "5", "6" ] + steps: + - name: Check out repository + uses: actions/checkout@v2 + - name: Set up python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install Pytest ${{ matrix.pytest-version }} + run: pip install pytest==${{ matrix.pytest-version }} + - name: Install package + run: pip install -e . + - name: Run tests + run: pytest --verbose --assert=plain diff --git a/.gitignore b/.gitignore index 20a20f0..bde2888 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,4 @@ venv.bak/ .idea/ -/src/*/_version.py \ No newline at end of file +/src/*/_version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 50ebd44..a8768c9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,36 @@ -- repo: https://github.com/ambv/black - rev: stable +repos: + - repo: https://github.com/ambv/black + rev: 20.8b1 hooks: - - id: black - language_version: python3.7 -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.4.0 + - id: black + args: ['--quiet'] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.4.0 hooks: - - id: debug-statements - - id: flake8 \ No newline at end of file + - id: check-ast + - id: check-added-large-files + - id: check-merge-conflict + - id: check-case-conflict + - id: check-docstring-first + - id: check-json + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: trailing-whitespace + - id: mixed-line-ending + - id: trailing-whitespace + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 + hooks: + - id: flake8 + additional_dependencies: [ + 'flake8-bugbear', + 'flake8-comprehensions', + 'flake8-deprecated', + 'flake8-print', + 'flake8-type-checking', + ] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v0.812' + hooks: + - id: mypy diff --git a/LICENCE b/LICENCE index 7b4f393..da2cc2c 100644 --- a/LICENCE +++ b/LICENCE @@ -17,4 +17,4 @@ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 209a791..2668d8d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Pytest plugin which splits the test suite to equally sized "sub suites" based on test execution time. ## Motivation -* Splitting the test suite is a prerequisite for parallelization (who does not want faster CI builds?). It's valuable to have sub suites which execution time is around the same. +* Splitting the test suite is a prerequisite for parallelization (who does not want faster CI builds?). It's valuable to have sub suites which execution time is around the same. * [`pytest-test-groups`](https://pypi.org/project/pytest-test-groups/) is great but it does not take into account the execution time of sub suites which can lead to notably unbalanced execution times between the sub suites. * [`pytest-xdist`](https://pypi.org/project/pytest-xdist/) is great but it's not suitable for all use cases. For example, some test suites may be fragile considering the order in which the tests are executed. @@ -21,7 +21,7 @@ pip install pytest-split ## Usage First we have to store test durations from a complete test suite run. -This produces .test_durations file which should be stored in the repo in order to have it available during future test runs. +This produces .test_durations file which should be stored in the repo in order to have it available during future test runs. The file path is configurable via `--durations-path` CLI option. ``` pytest --store-durations diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d367b22 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.black] +line-length = 88 +include = '\.pyi?$' diff --git a/setup.cfg b/setup.cfg index 1d36346..ad1c8fe 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,21 @@ [flake8] -max-line-length = 88 \ No newline at end of file +max-line-length = 88 +select = + # B: flake8-bugbear + B, + # B950: opt-in flake8-bugbear check: line too long + B950, + # C: complexity, comprehensions, etc + C, + # E: pycodestyle errors + E, + # F: pyflakes violations + F, + # W: pycodestyle warnings + W, + # T: flake8-print, prevent prints + T, + # TC: flake8-type-checking + TC, + # TC1: flake8-type-checking, use futures imports for forward reference management(could have used TC2 instead) + TC2 diff --git a/setup.py b/setup.py index a70db26..5643599 100644 --- a/setup.py +++ b/setup.py @@ -1,16 +1,13 @@ import setuptools - with open("README.md", "r") as readme_file: long_description = readme_file.read() - tests_require = ["pytest", "pytest-cov", "pre-commit"] - setuptools.setup( name="pytest-split", - use_scm_version=dict(write_to="src/pytest_split/_version.py"), + use_scm_version={"write_to": "src/pytest_split/_version.py"}, author="Jerry Pussinen", author_email="jerry.pussinen@gmail.com", description="Pytest plugin for splitting test suite based on test execution time", @@ -25,14 +22,16 @@ install_requires=["pytest"], extras_require={"testing": tests_require}, classifiers=[ - "Development Status :: 1 - Planning", - "Intended Audience :: Developers", + "Development Status :: 4 - Beta" "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Framework :: Pytest", + "Typing :: Typed", ], entry_points={"pytest11": ["pytest-split = pytest_split.plugin"]}, + package_data={"pytest_split": ["py.typed"]}, ) diff --git a/src/pytest_split/plugin.py b/src/pytest_split/plugin.py index e87ff75..5db7676 100644 --- a/src/pytest_split/plugin.py +++ b/src/pytest_split/plugin.py @@ -1,14 +1,23 @@ import json import os from collections import defaultdict, OrderedDict +from typing import TYPE_CHECKING from _pytest.config import create_terminal_writer +if TYPE_CHECKING: + from typing import List, Tuple + from _pytest.config.argparsing import Parser + from _pytest.main import Session + + from _pytest import nodes + from _pytest.config import Config + # Ugly hacks for freezegun compatibility: https://github.com/spulec/freezegun/issues/286 STORE_DURATIONS_SETUP_AND_TEARDOWN_THRESHOLD = 60 * 10 # seconds -def pytest_addoption(parser): +def pytest_addoption(parser: "Parser") -> None: group = parser.getgroup( "Split tests into groups which execution time is about the same. " "Run first the whole suite with --store-durations to save information " @@ -43,7 +52,7 @@ def pytest_addoption(parser): ) -def pytest_collection_modifyitems(session, config, items): +def pytest_collection_modifyitems(config: "Config", items: "List[nodes.Item]") -> None: splits = config.option.splits group = config.option.group store_durations = config.option.store_durations @@ -51,12 +60,12 @@ def pytest_collection_modifyitems(session, config, items): if any((splits, group)): if not all((splits, group)): - return + return None if not os.path.isfile(durations_report_path): - return + return None if store_durations: # Don't split if we are storing durations - return + return None total_tests_count = len(items) if splits and group: with open(durations_report_path) as f: @@ -75,19 +84,21 @@ def pytest_collection_modifyitems(session, config, items): ) ) terminal_reporter.write(message) + return None -def pytest_sessionfinish(session, exitstatus): +def pytest_sessionfinish(session: "Session") -> None: if session.config.option.store_durations: report_path = session.config.option.durations_path terminal_reporter = session.config.pluginmanager.get_plugin("terminalreporter") - durations = defaultdict(float) + durations: dict = defaultdict(float) for test_reports in terminal_reporter.stats.values(): for test_report in test_reports: if hasattr(test_report, "duration"): stage = getattr(test_report, "when", "") duration = test_report.duration - # These ifs be removed after this is solved: https://github.com/spulec/freezegun/issues/286 + # These ifs be removed after this is solved: + # https://github.com/spulec/freezegun/issues/286 if duration < 0: continue if ( @@ -108,9 +119,13 @@ def pytest_sessionfinish(session, exitstatus): terminal_reporter.write(message) -def _calculate_suite_start_and_end_idx(splits, group, items, stored_durations): +def _calculate_suite_start_and_end_idx( + splits: int, group: int, items: "List[nodes.Item]", stored_durations: OrderedDict +) -> "Tuple[int, int]": item_node_ids = [item.nodeid for item in items] - stored_durations = {k: v for k, v in stored_durations.items() if k in item_node_ids} + stored_durations = OrderedDict( + {k: v for k, v in stored_durations.items() if k in item_node_ids} + ) avg_duration_per_test = sum(stored_durations.values()) / len(stored_durations) durations = OrderedDict() diff --git a/src/pytest_split/py.typed b/src/pytest_split/py.typed new file mode 100644 index 0000000..e69de29