pytest-balance 0.1.0a1__tar.gz
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.
- pytest_balance-0.1.0a1/.commitlintrc.yml +2 -0
- pytest_balance-0.1.0a1/.github/workflows/ci.yml +103 -0
- pytest_balance-0.1.0a1/.github/workflows/release.yml +115 -0
- pytest_balance-0.1.0a1/.gitignore +33 -0
- pytest_balance-0.1.0a1/LICENSE +21 -0
- pytest_balance-0.1.0a1/Makefile +28 -0
- pytest_balance-0.1.0a1/PKG-INFO +348 -0
- pytest_balance-0.1.0a1/README.md +315 -0
- pytest_balance-0.1.0a1/cliff.toml +58 -0
- pytest_balance-0.1.0a1/pyproject.toml +98 -0
- pytest_balance-0.1.0a1/src/pytest_balance/__init__.py +3 -0
- pytest_balance-0.1.0a1/src/pytest_balance/__main__.py +5 -0
- pytest_balance-0.1.0a1/src/pytest_balance/algorithms/__init__.py +0 -0
- pytest_balance-0.1.0a1/src/pytest_balance/algorithms/lpt.py +38 -0
- pytest_balance-0.1.0a1/src/pytest_balance/algorithms/partitioner.py +62 -0
- pytest_balance-0.1.0a1/src/pytest_balance/ci/__init__.py +0 -0
- pytest_balance-0.1.0a1/src/pytest_balance/ci/detect.py +209 -0
- pytest_balance-0.1.0a1/src/pytest_balance/ci/splitter.py +46 -0
- pytest_balance-0.1.0a1/src/pytest_balance/cli.py +340 -0
- pytest_balance-0.1.0a1/src/pytest_balance/plugin.py +253 -0
- pytest_balance-0.1.0a1/src/pytest_balance/py.typed +0 -0
- pytest_balance-0.1.0a1/src/pytest_balance/report.py +64 -0
- pytest_balance-0.1.0a1/src/pytest_balance/store/__init__.py +0 -0
- pytest_balance-0.1.0a1/src/pytest_balance/store/merger.py +53 -0
- pytest_balance-0.1.0a1/src/pytest_balance/store/models.py +40 -0
- pytest_balance-0.1.0a1/src/pytest_balance/store/reader.py +89 -0
- pytest_balance-0.1.0a1/src/pytest_balance/store/writer.py +52 -0
- pytest_balance-0.1.0a1/src/pytest_balance/xdist/__init__.py +0 -0
- pytest_balance-0.1.0a1/src/pytest_balance/xdist/hooks.py +36 -0
- pytest_balance-0.1.0a1/src/pytest_balance/xdist/scheduler.py +361 -0
- pytest_balance-0.1.0a1/tests/__init__.py +0 -0
- pytest_balance-0.1.0a1/tests/conftest.py +1 -0
- pytest_balance-0.1.0a1/tests/test_algorithms/__init__.py +0 -0
- pytest_balance-0.1.0a1/tests/test_algorithms/test_lpt.py +52 -0
- pytest_balance-0.1.0a1/tests/test_algorithms/test_partitioner.py +61 -0
- pytest_balance-0.1.0a1/tests/test_ci/__init__.py +0 -0
- pytest_balance-0.1.0a1/tests/test_ci/test_detect.py +116 -0
- pytest_balance-0.1.0a1/tests/test_ci/test_splitter.py +67 -0
- pytest_balance-0.1.0a1/tests/test_cli.py +128 -0
- pytest_balance-0.1.0a1/tests/test_plugin.py +152 -0
- pytest_balance-0.1.0a1/tests/test_report.py +39 -0
- pytest_balance-0.1.0a1/tests/test_store/__init__.py +0 -0
- pytest_balance-0.1.0a1/tests/test_store/test_merger.py +68 -0
- pytest_balance-0.1.0a1/tests/test_store/test_models.py +65 -0
- pytest_balance-0.1.0a1/tests/test_store/test_reader.py +77 -0
- pytest_balance-0.1.0a1/tests/test_store/test_writer.py +62 -0
- pytest_balance-0.1.0a1/tests/test_xdist/__init__.py +0 -0
- pytest_balance-0.1.0a1/tests/test_xdist/test_scheduler.py +469 -0
- pytest_balance-0.1.0a1/uv.lock +507 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
permissions: {}
|
|
4
|
+
|
|
5
|
+
concurrency:
|
|
6
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
7
|
+
cancel-in-progress: true
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
push:
|
|
11
|
+
branches: [main]
|
|
12
|
+
pull_request:
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
commitlint:
|
|
16
|
+
name: Commit messages
|
|
17
|
+
if: github.event_name == 'pull_request'
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
permissions:
|
|
20
|
+
contents: read
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
23
|
+
with:
|
|
24
|
+
fetch-depth: 0
|
|
25
|
+
- uses: wagoid/commitlint-github-action@f133a0d95090ef2609192b4a21f54e20af819ea9 # v6
|
|
26
|
+
|
|
27
|
+
format:
|
|
28
|
+
name: Format
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
permissions:
|
|
31
|
+
contents: read
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
34
|
+
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
|
35
|
+
with:
|
|
36
|
+
enable-cache: true
|
|
37
|
+
- run: uv sync
|
|
38
|
+
- run: uv run ruff format --check src tests
|
|
39
|
+
|
|
40
|
+
lint:
|
|
41
|
+
name: Lint
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
permissions:
|
|
44
|
+
contents: read
|
|
45
|
+
steps:
|
|
46
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
47
|
+
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
|
48
|
+
with:
|
|
49
|
+
enable-cache: true
|
|
50
|
+
- run: uv sync
|
|
51
|
+
- run: uv run ruff check src tests
|
|
52
|
+
|
|
53
|
+
typecheck:
|
|
54
|
+
name: Typecheck
|
|
55
|
+
runs-on: ubuntu-latest
|
|
56
|
+
permissions:
|
|
57
|
+
contents: read
|
|
58
|
+
steps:
|
|
59
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
60
|
+
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
|
61
|
+
with:
|
|
62
|
+
enable-cache: true
|
|
63
|
+
- run: uv sync
|
|
64
|
+
- run: uv run mypy src
|
|
65
|
+
|
|
66
|
+
test:
|
|
67
|
+
name: Test (Python ${{ matrix.python-version }})
|
|
68
|
+
runs-on: ubuntu-latest
|
|
69
|
+
permissions:
|
|
70
|
+
contents: read
|
|
71
|
+
strategy:
|
|
72
|
+
fail-fast: false
|
|
73
|
+
matrix:
|
|
74
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
75
|
+
steps:
|
|
76
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
77
|
+
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
|
78
|
+
with:
|
|
79
|
+
enable-cache: true
|
|
80
|
+
- run: uv sync --python ${{ matrix.python-version }}
|
|
81
|
+
- run: uv run pytest --cov --cov-report=xml --tb=short
|
|
82
|
+
if: matrix.python-version == '3.14'
|
|
83
|
+
- run: uv run pytest --tb=short
|
|
84
|
+
if: matrix.python-version != '3.14'
|
|
85
|
+
- uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
|
|
86
|
+
if: matrix.python-version == '3.14'
|
|
87
|
+
with:
|
|
88
|
+
files: coverage.xml
|
|
89
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
90
|
+
fail_ci_if_error: false
|
|
91
|
+
|
|
92
|
+
test-without-xdist:
|
|
93
|
+
name: Test (without xdist)
|
|
94
|
+
runs-on: ubuntu-latest
|
|
95
|
+
permissions:
|
|
96
|
+
contents: read
|
|
97
|
+
steps:
|
|
98
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
99
|
+
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
|
100
|
+
with:
|
|
101
|
+
enable-cache: true
|
|
102
|
+
- run: uv sync --no-group dev
|
|
103
|
+
- run: uv run pytest --tb=short -k "not test_xdist"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
permissions: {}
|
|
8
|
+
|
|
9
|
+
concurrency:
|
|
10
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
11
|
+
cancel-in-progress: false
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
ci-check:
|
|
15
|
+
name: CI Gate
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
timeout-minutes: 10
|
|
18
|
+
permissions:
|
|
19
|
+
contents: read
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
22
|
+
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
|
23
|
+
with:
|
|
24
|
+
enable-cache: true
|
|
25
|
+
|
|
26
|
+
- name: Verify tag matches package version
|
|
27
|
+
env:
|
|
28
|
+
TAG_REF: ${{ github.ref }}
|
|
29
|
+
run: |
|
|
30
|
+
TAG="${TAG_REF#refs/tags/v}"
|
|
31
|
+
PY_VER=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
32
|
+
echo "Tag: $TAG | pyproject: $PY_VER"
|
|
33
|
+
if [ "$TAG" != "$PY_VER" ]; then
|
|
34
|
+
echo "::error::Tag v$TAG does not match pyproject.toml version $PY_VER"
|
|
35
|
+
exit 1
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
- run: uv sync
|
|
39
|
+
- run: uv run ruff format --check src tests
|
|
40
|
+
- run: uv run ruff check src tests
|
|
41
|
+
- run: uv run mypy src
|
|
42
|
+
- run: uv run pytest --tb=short
|
|
43
|
+
|
|
44
|
+
build:
|
|
45
|
+
name: Build package
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
timeout-minutes: 5
|
|
48
|
+
permissions:
|
|
49
|
+
contents: read
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
52
|
+
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
|
53
|
+
- run: uv build
|
|
54
|
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
55
|
+
with:
|
|
56
|
+
name: dist
|
|
57
|
+
path: dist/
|
|
58
|
+
|
|
59
|
+
publish:
|
|
60
|
+
name: Publish to PyPI
|
|
61
|
+
needs: [build, ci-check]
|
|
62
|
+
runs-on: ubuntu-latest
|
|
63
|
+
timeout-minutes: 5
|
|
64
|
+
environment: PyPI
|
|
65
|
+
permissions:
|
|
66
|
+
id-token: write
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
|
69
|
+
with:
|
|
70
|
+
name: dist
|
|
71
|
+
path: dist/
|
|
72
|
+
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
|
73
|
+
with:
|
|
74
|
+
attestations: true
|
|
75
|
+
|
|
76
|
+
release:
|
|
77
|
+
name: GitHub Release
|
|
78
|
+
needs: [build, ci-check]
|
|
79
|
+
runs-on: ubuntu-latest
|
|
80
|
+
timeout-minutes: 5
|
|
81
|
+
permissions:
|
|
82
|
+
contents: write
|
|
83
|
+
steps:
|
|
84
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
85
|
+
with:
|
|
86
|
+
fetch-depth: 0
|
|
87
|
+
|
|
88
|
+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
|
89
|
+
with:
|
|
90
|
+
name: dist
|
|
91
|
+
path: dist/
|
|
92
|
+
|
|
93
|
+
- name: Generate checksums
|
|
94
|
+
run: |
|
|
95
|
+
cd dist
|
|
96
|
+
sha256sum * > SHA256SUMS
|
|
97
|
+
|
|
98
|
+
- name: Generate release notes
|
|
99
|
+
id: cliff
|
|
100
|
+
uses: orhun/git-cliff-action@c93ef52f3d0ddcdcc9bd5447d98d458a11cd4f72 # v4
|
|
101
|
+
with:
|
|
102
|
+
config: cliff.toml
|
|
103
|
+
args: --latest --strip header
|
|
104
|
+
env:
|
|
105
|
+
GITHUB_REPO: ${{ github.repository }}
|
|
106
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
107
|
+
|
|
108
|
+
- uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
|
109
|
+
with:
|
|
110
|
+
body: ${{ steps.cliff.outputs.content }}
|
|
111
|
+
prerelease: ${{ contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc') }}
|
|
112
|
+
files: |
|
|
113
|
+
dist/*.whl
|
|
114
|
+
dist/*.tar.gz
|
|
115
|
+
dist/SHA256SUMS
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
|
|
9
|
+
# Tool caches (pytest, mypy, ruff)
|
|
10
|
+
.cache/
|
|
11
|
+
|
|
12
|
+
# Virtual environments
|
|
13
|
+
.venv/
|
|
14
|
+
|
|
15
|
+
# Duration store
|
|
16
|
+
.balance/
|
|
17
|
+
|
|
18
|
+
# IDE
|
|
19
|
+
.idea/
|
|
20
|
+
.vscode/
|
|
21
|
+
*.swp
|
|
22
|
+
|
|
23
|
+
# OS
|
|
24
|
+
.DS_Store
|
|
25
|
+
|
|
26
|
+
# Coverage
|
|
27
|
+
.coverage
|
|
28
|
+
|
|
29
|
+
# Review
|
|
30
|
+
.full-review/
|
|
31
|
+
|
|
32
|
+
# Ongoing documentation
|
|
33
|
+
docs/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Geoffrey Guéret
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
.PHONY: install lint typecheck test format check all clean
|
|
2
|
+
|
|
3
|
+
install:
|
|
4
|
+
uv sync
|
|
5
|
+
|
|
6
|
+
lint:
|
|
7
|
+
uv run ruff check src tests
|
|
8
|
+
|
|
9
|
+
typecheck:
|
|
10
|
+
uv run mypy src
|
|
11
|
+
|
|
12
|
+
test:
|
|
13
|
+
uv run pytest
|
|
14
|
+
|
|
15
|
+
format:
|
|
16
|
+
uv run ruff format src tests
|
|
17
|
+
uv run ruff check --fix src tests
|
|
18
|
+
|
|
19
|
+
check:
|
|
20
|
+
uv run ruff format --check src tests
|
|
21
|
+
uv run ruff check src tests
|
|
22
|
+
uv run mypy src
|
|
23
|
+
|
|
24
|
+
all: lint typecheck test
|
|
25
|
+
|
|
26
|
+
clean:
|
|
27
|
+
rm -rf .cache .coverage htmlcov dist build *.egg-info
|
|
28
|
+
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-balance
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Intelligent test distribution for pytest based on actual execution times, not file count
|
|
5
|
+
Project-URL: Homepage, https://github.com/ggueret/pytest-balance
|
|
6
|
+
Project-URL: Repository, https://github.com/ggueret/pytest-balance.git
|
|
7
|
+
Project-URL: Documentation, https://github.com/ggueret/pytest-balance
|
|
8
|
+
Project-URL: Issues, https://github.com/ggueret/pytest-balance/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/ggueret/pytest-balance/releases
|
|
10
|
+
Author-email: Geoffrey Guéret <geoffrey@gueret.dev>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: CI,distribution,load-balancing,pytest,testing,xdist
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Framework :: Pytest
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
26
|
+
Classifier: Topic :: Software Development :: Testing
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Requires-Dist: pytest>=8
|
|
30
|
+
Provides-Extra: xdist
|
|
31
|
+
Requires-Dist: pytest-xdist>=3.5; extra == 'xdist'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# pytest-balance
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/pytest-balance/)
|
|
37
|
+
[](https://pypi.org/project/pytest-balance/)
|
|
38
|
+
[](https://github.com/ggueret/pytest-balance/blob/main/LICENSE)
|
|
39
|
+
|
|
40
|
+
Intelligent test distribution for pytest. Split your test suite across CI runners and
|
|
41
|
+
xdist workers based on actual execution times, not file count.
|
|
42
|
+
|
|
43
|
+
Most CI parallelism strategies split tests naively: round-robin, alphabetical, or by file
|
|
44
|
+
count. The result is predictable. One runner finishes in 2 minutes, another grinds for
|
|
45
|
+
12, and your pipeline is only as fast as the slowest shard.
|
|
46
|
+
|
|
47
|
+
**pytest-balance fixes this.** It records test durations, learns from them, and uses a
|
|
48
|
+
scheduling algorithm with real guarantees to spread the load evenly.
|
|
49
|
+
|
|
50
|
+
### What makes it different
|
|
51
|
+
|
|
52
|
+
- **LPT scheduling.** The Longest Processing Time First algorithm assigns the heaviest
|
|
53
|
+
test groups first and greedily fills the lightest bucket. This minimizes your total wall
|
|
54
|
+
time with a proven worst-case bound of 4/3 optimal.
|
|
55
|
+
- **Deterministic partitioning.** Given the same duration data and the same test
|
|
56
|
+
collection, every CI run produces the exact same split. No flaky ordering, no
|
|
57
|
+
cache-busting surprises, no "works on my shard" mysteries. Ties are broken
|
|
58
|
+
lexicographically, so the output is reproducible down to the test.
|
|
59
|
+
- **Scope-aware grouping.** Tests that share module or class fixtures stay together,
|
|
60
|
+
avoiding expensive teardown/setup cycles across nodes.
|
|
61
|
+
- **Work-stealing.** When used with pytest-xdist, idle workers steal complete test groups
|
|
62
|
+
from the busiest worker at runtime. Static estimates are never perfect;
|
|
63
|
+
work-stealing closes the gap.
|
|
64
|
+
- **Adaptive estimation.** An exponential moving average (EMA) tracks duration trends
|
|
65
|
+
over time, so a test that got slower last week weighs more than one that was slow six
|
|
66
|
+
months ago.
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install pytest-balance
|
|
72
|
+
|
|
73
|
+
# With pytest-xdist support
|
|
74
|
+
pip install pytest-balance[xdist]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Quick Start
|
|
78
|
+
|
|
79
|
+
**Step 1: record durations** on your first run (or in a baseline pipeline step):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pytest --balance-store
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This writes `.balance/durations.jsonl` (locally) or `.balance/durations-<run_id>-<node>.jsonl`
|
|
86
|
+
(in CI). After a parallel CI run, merge the partial files:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pytest-balance merge
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Step 2: distribute tests** using the recorded data:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pytest --balance
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
In CI, the plugin auto-detects the node index and total from the environment and runs only
|
|
99
|
+
the slice assigned to the current node.
|
|
100
|
+
|
|
101
|
+
## CI Integration
|
|
102
|
+
|
|
103
|
+
### GitHub Actions
|
|
104
|
+
|
|
105
|
+
GitHub Actions does not expose parallel job indices natively. Pass them from the matrix:
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
jobs:
|
|
109
|
+
test:
|
|
110
|
+
runs-on: ubuntu-latest
|
|
111
|
+
strategy:
|
|
112
|
+
matrix:
|
|
113
|
+
shard: [0, 1, 2, 3]
|
|
114
|
+
env:
|
|
115
|
+
PYTEST_BALANCE_NODE_INDEX: ${{ matrix.shard }}
|
|
116
|
+
PYTEST_BALANCE_NODE_TOTAL: 4
|
|
117
|
+
steps:
|
|
118
|
+
- uses: actions/checkout@v4
|
|
119
|
+
- run: pip install pytest-balance
|
|
120
|
+
- run: pytest --balance --balance-store
|
|
121
|
+
- uses: actions/upload-artifact@v4
|
|
122
|
+
with:
|
|
123
|
+
name: durations-${{ matrix.shard }}
|
|
124
|
+
path: .balance/durations-*.jsonl
|
|
125
|
+
|
|
126
|
+
merge-durations:
|
|
127
|
+
needs: test
|
|
128
|
+
runs-on: ubuntu-latest
|
|
129
|
+
steps:
|
|
130
|
+
- uses: actions/download-artifact@v4
|
|
131
|
+
- run: pip install pytest-balance
|
|
132
|
+
- run: pytest-balance merge durations-*/durations-*.jsonl -o .balance/durations.jsonl
|
|
133
|
+
- uses: actions/upload-artifact@v4
|
|
134
|
+
with:
|
|
135
|
+
name: balance-store
|
|
136
|
+
path: .balance/durations.jsonl
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### GitLab CI
|
|
140
|
+
|
|
141
|
+
GitLab's `parallel:` keyword sets `CI_NODE_INDEX` (1-based) and `CI_NODE_TOTAL`
|
|
142
|
+
automatically. The plugin converts the 1-based index to 0-based internally.
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
test:
|
|
146
|
+
image: python:3.14
|
|
147
|
+
parallel: 4
|
|
148
|
+
script:
|
|
149
|
+
- pip install pytest-balance
|
|
150
|
+
- pytest --balance --balance-store
|
|
151
|
+
artifacts:
|
|
152
|
+
paths:
|
|
153
|
+
- .balance/durations-*.jsonl
|
|
154
|
+
expire_in: 7 days
|
|
155
|
+
|
|
156
|
+
merge-durations:
|
|
157
|
+
image: python:3.14
|
|
158
|
+
stage: .post
|
|
159
|
+
needs: [test]
|
|
160
|
+
script:
|
|
161
|
+
- pip install pytest-balance
|
|
162
|
+
- pytest-balance merge .balance/durations-*.jsonl -o .balance/durations.jsonl
|
|
163
|
+
artifacts:
|
|
164
|
+
paths:
|
|
165
|
+
- .balance/durations.jsonl
|
|
166
|
+
expire_in: 30 days
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### CircleCI
|
|
170
|
+
|
|
171
|
+
CircleCI's `parallelism:` sets `CIRCLE_NODE_INDEX` (0-based) and `CIRCLE_NODE_TOTAL`
|
|
172
|
+
automatically.
|
|
173
|
+
|
|
174
|
+
```yaml
|
|
175
|
+
jobs:
|
|
176
|
+
test:
|
|
177
|
+
docker:
|
|
178
|
+
- image: cimg/python:3.14
|
|
179
|
+
parallelism: 4
|
|
180
|
+
steps:
|
|
181
|
+
- checkout
|
|
182
|
+
- run: pip install pytest-balance
|
|
183
|
+
- run: pytest --balance --balance-store
|
|
184
|
+
- store_artifacts:
|
|
185
|
+
path: .balance/
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Azure DevOps
|
|
189
|
+
|
|
190
|
+
Azure Pipelines sets `SYSTEM_JOBPOSITIONINPHASE` (1-based) and `SYSTEM_TOTALJOBSINPHASE`
|
|
191
|
+
when using a matrix or parallel strategy. The plugin converts to 0-based internally.
|
|
192
|
+
|
|
193
|
+
### Buildkite
|
|
194
|
+
|
|
195
|
+
Buildkite sets `BUILDKITE_PARALLEL_JOB` (0-based) and `BUILDKITE_PARALLEL_JOB_COUNT`
|
|
196
|
+
when `parallelism:` is configured in the pipeline.
|
|
197
|
+
|
|
198
|
+
### Generic / other CI
|
|
199
|
+
|
|
200
|
+
Set `PYTEST_BALANCE_NODE_INDEX` and `PYTEST_BALANCE_NODE_TOTAL` manually on any CI system
|
|
201
|
+
that does not have native parallelism variables.
|
|
202
|
+
|
|
203
|
+
## xdist Integration
|
|
204
|
+
|
|
205
|
+
When `pytest-balance[xdist]` is installed, passing `--balance` alongside `-n` activates
|
|
206
|
+
the `BalanceScheduler` instead of the default xdist load scheduler:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
pytest -n 4 --balance
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The scheduler uses LPT pre-assignment (see How It Works) and falls back to work-stealing
|
|
213
|
+
at runtime when workers finish early. `--dist each` is incompatible with `--balance`.
|
|
214
|
+
|
|
215
|
+
## CLI Options
|
|
216
|
+
|
|
217
|
+
All options are available as pytest command-line flags:
|
|
218
|
+
|
|
219
|
+
| Flag | Default | Description |
|
|
220
|
+
|---|---|---|
|
|
221
|
+
| `--balance` | off | Enable balanced test distribution across CI nodes |
|
|
222
|
+
| `--balance-store` | off | Record test durations to the balance store |
|
|
223
|
+
| `--balance-scope` | `module` | Grouping scope: `test`, `class`, `module`, `group` |
|
|
224
|
+
| `--balance-path` | `.balance/` | Path to the balance store directory |
|
|
225
|
+
| `--balance-plan` | off | Show the distribution plan without running tests (requires `--balance`) |
|
|
226
|
+
| `--balance-node-index` | auto | Explicit node index (overrides CI auto-detection) |
|
|
227
|
+
| `--balance-node-total` | auto | Explicit total node count (overrides CI auto-detection) |
|
|
228
|
+
| `--balance-estimator` | `ema` | Duration estimation strategy: `ema`, `median`, `last` |
|
|
229
|
+
| `--balance-no-report` | off | Suppress the balance summary after the test run |
|
|
230
|
+
|
|
231
|
+
## Standalone CLI
|
|
232
|
+
|
|
233
|
+
The `pytest-balance` command manages the duration store outside of a test run.
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
pytest-balance [--path PATH] <command> [options]
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### merge
|
|
240
|
+
|
|
241
|
+
Merge per-node partial files into a single `durations.jsonl`:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
pytest-balance merge
|
|
245
|
+
pytest-balance merge .balance/durations-abc-0.jsonl .balance/durations-abc-1.jsonl
|
|
246
|
+
pytest-balance merge -o custom/path/durations.jsonl
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
After merging, the partial files are deleted automatically.
|
|
250
|
+
|
|
251
|
+
### prune
|
|
252
|
+
|
|
253
|
+
Remove old run data, keeping only the most recent runs per test:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
pytest-balance prune
|
|
257
|
+
pytest-balance prune --keep-runs 20
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Default is 50 runs. Entries without a `run_id` are always kept.
|
|
261
|
+
|
|
262
|
+
### stats
|
|
263
|
+
|
|
264
|
+
Display a summary of the duration store:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
pytest-balance stats
|
|
268
|
+
pytest-balance stats --json
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Output includes total tests, total and average estimated time, and the slowest and fastest
|
|
272
|
+
tests.
|
|
273
|
+
|
|
274
|
+
### plan
|
|
275
|
+
|
|
276
|
+
Preview how tests would be distributed for a given node count:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
pytest-balance plan 4
|
|
280
|
+
pytest-balance plan 4 --scope class --estimator median --json
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Duration Store
|
|
284
|
+
|
|
285
|
+
Durations are stored as JSONL (one JSON object per line) in `.balance/durations.jsonl`.
|
|
286
|
+
Each line records a single test result:
|
|
287
|
+
|
|
288
|
+
```json
|
|
289
|
+
{"test_id":"tests/test_api.py::test_login","duration":0.42,"timestamp":"2024-01-15T10:30:00+00:00","run_id":"12345-1","worker":"node0","phase":"call"}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**In CI:** each parallel node writes to a separate partial file
|
|
293
|
+
(`durations-<run_id>-<node_index>.jsonl`) to avoid write conflicts. Run
|
|
294
|
+
`pytest-balance merge` after all nodes finish to consolidate them.
|
|
295
|
+
|
|
296
|
+
**Locally:** durations are appended directly to `durations.jsonl`.
|
|
297
|
+
|
|
298
|
+
Commit `durations.jsonl` to version control so all branches and CI runs share the same
|
|
299
|
+
history.
|
|
300
|
+
|
|
301
|
+
## Scope
|
|
302
|
+
|
|
303
|
+
The `--balance-scope` option controls how tests are grouped before partitioning:
|
|
304
|
+
|
|
305
|
+
| Scope | Grouping | When to use |
|
|
306
|
+
|---|---|---|
|
|
307
|
+
| `test` | Each test is its own unit | Tests are fully independent and durations vary widely |
|
|
308
|
+
| `class` | All tests in a class are kept together | Tests share class-level fixtures |
|
|
309
|
+
| `module` | All tests in a file are kept together (default) | Tests share module-level fixtures |
|
|
310
|
+
| `group` | Tests tagged with `@<group>` in their node ID | Custom grouping via markers |
|
|
311
|
+
|
|
312
|
+
Keeping related tests together avoids fixture teardown/setup overhead between nodes.
|
|
313
|
+
The xdist work-stealing also respects scope boundaries, stealing complete groups rather
|
|
314
|
+
than splitting them.
|
|
315
|
+
|
|
316
|
+
## How It Works
|
|
317
|
+
|
|
318
|
+
**CI-level splitting (--balance):**
|
|
319
|
+
|
|
320
|
+
1. On collection, the plugin reads duration estimates from the store.
|
|
321
|
+
2. Tests are grouped by the configured scope.
|
|
322
|
+
3. The Longest Processing Time First (LPT) algorithm assigns groups to nodes: sort groups
|
|
323
|
+
by descending estimated duration, then greedily assign each group to the node with the
|
|
324
|
+
currently lowest total load.
|
|
325
|
+
4. Only the slice for the current node index runs; the rest are deselected.
|
|
326
|
+
|
|
327
|
+
**xdist scheduling (--balance with -n):**
|
|
328
|
+
|
|
329
|
+
1. After all workers collect, the same LPT algorithm pre-assigns groups to workers and
|
|
330
|
+
sends each worker its initial batch.
|
|
331
|
+
2. As workers finish, idle workers steal complete scope groups from the busiest worker.
|
|
332
|
+
With `--balance-scope test`, individual tests can be stolen instead of groups.
|
|
333
|
+
3. Workers with no remaining work are shut down so the run ends as soon as all tests
|
|
334
|
+
complete.
|
|
335
|
+
|
|
336
|
+
**Estimation strategies:**
|
|
337
|
+
|
|
338
|
+
- `ema` (default): exponential moving average (alpha=0.3) over the recorded history,
|
|
339
|
+
giving more weight to recent runs.
|
|
340
|
+
- `median`: statistical median over all recorded durations.
|
|
341
|
+
- `last`: the single most recent recorded duration.
|
|
342
|
+
|
|
343
|
+
Unknown tests (not in the store) fall back to the median estimated duration of all known
|
|
344
|
+
tests.
|
|
345
|
+
|
|
346
|
+
## Status
|
|
347
|
+
|
|
348
|
+
Alpha. The API and file format may change between releases.
|