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.
Files changed (49) hide show
  1. pytest_balance-0.1.0a1/.commitlintrc.yml +2 -0
  2. pytest_balance-0.1.0a1/.github/workflows/ci.yml +103 -0
  3. pytest_balance-0.1.0a1/.github/workflows/release.yml +115 -0
  4. pytest_balance-0.1.0a1/.gitignore +33 -0
  5. pytest_balance-0.1.0a1/LICENSE +21 -0
  6. pytest_balance-0.1.0a1/Makefile +28 -0
  7. pytest_balance-0.1.0a1/PKG-INFO +348 -0
  8. pytest_balance-0.1.0a1/README.md +315 -0
  9. pytest_balance-0.1.0a1/cliff.toml +58 -0
  10. pytest_balance-0.1.0a1/pyproject.toml +98 -0
  11. pytest_balance-0.1.0a1/src/pytest_balance/__init__.py +3 -0
  12. pytest_balance-0.1.0a1/src/pytest_balance/__main__.py +5 -0
  13. pytest_balance-0.1.0a1/src/pytest_balance/algorithms/__init__.py +0 -0
  14. pytest_balance-0.1.0a1/src/pytest_balance/algorithms/lpt.py +38 -0
  15. pytest_balance-0.1.0a1/src/pytest_balance/algorithms/partitioner.py +62 -0
  16. pytest_balance-0.1.0a1/src/pytest_balance/ci/__init__.py +0 -0
  17. pytest_balance-0.1.0a1/src/pytest_balance/ci/detect.py +209 -0
  18. pytest_balance-0.1.0a1/src/pytest_balance/ci/splitter.py +46 -0
  19. pytest_balance-0.1.0a1/src/pytest_balance/cli.py +340 -0
  20. pytest_balance-0.1.0a1/src/pytest_balance/plugin.py +253 -0
  21. pytest_balance-0.1.0a1/src/pytest_balance/py.typed +0 -0
  22. pytest_balance-0.1.0a1/src/pytest_balance/report.py +64 -0
  23. pytest_balance-0.1.0a1/src/pytest_balance/store/__init__.py +0 -0
  24. pytest_balance-0.1.0a1/src/pytest_balance/store/merger.py +53 -0
  25. pytest_balance-0.1.0a1/src/pytest_balance/store/models.py +40 -0
  26. pytest_balance-0.1.0a1/src/pytest_balance/store/reader.py +89 -0
  27. pytest_balance-0.1.0a1/src/pytest_balance/store/writer.py +52 -0
  28. pytest_balance-0.1.0a1/src/pytest_balance/xdist/__init__.py +0 -0
  29. pytest_balance-0.1.0a1/src/pytest_balance/xdist/hooks.py +36 -0
  30. pytest_balance-0.1.0a1/src/pytest_balance/xdist/scheduler.py +361 -0
  31. pytest_balance-0.1.0a1/tests/__init__.py +0 -0
  32. pytest_balance-0.1.0a1/tests/conftest.py +1 -0
  33. pytest_balance-0.1.0a1/tests/test_algorithms/__init__.py +0 -0
  34. pytest_balance-0.1.0a1/tests/test_algorithms/test_lpt.py +52 -0
  35. pytest_balance-0.1.0a1/tests/test_algorithms/test_partitioner.py +61 -0
  36. pytest_balance-0.1.0a1/tests/test_ci/__init__.py +0 -0
  37. pytest_balance-0.1.0a1/tests/test_ci/test_detect.py +116 -0
  38. pytest_balance-0.1.0a1/tests/test_ci/test_splitter.py +67 -0
  39. pytest_balance-0.1.0a1/tests/test_cli.py +128 -0
  40. pytest_balance-0.1.0a1/tests/test_plugin.py +152 -0
  41. pytest_balance-0.1.0a1/tests/test_report.py +39 -0
  42. pytest_balance-0.1.0a1/tests/test_store/__init__.py +0 -0
  43. pytest_balance-0.1.0a1/tests/test_store/test_merger.py +68 -0
  44. pytest_balance-0.1.0a1/tests/test_store/test_models.py +65 -0
  45. pytest_balance-0.1.0a1/tests/test_store/test_reader.py +77 -0
  46. pytest_balance-0.1.0a1/tests/test_store/test_writer.py +62 -0
  47. pytest_balance-0.1.0a1/tests/test_xdist/__init__.py +0 -0
  48. pytest_balance-0.1.0a1/tests/test_xdist/test_scheduler.py +469 -0
  49. pytest_balance-0.1.0a1/uv.lock +507 -0
@@ -0,0 +1,2 @@
1
+ extends:
2
+ - "@commitlint/config-conventional"
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/pytest-balance)](https://pypi.org/project/pytest-balance/)
37
+ [![Python](https://img.shields.io/pypi/pyversions/pytest-balance)](https://pypi.org/project/pytest-balance/)
38
+ [![License](https://img.shields.io/github/license/ggueret/pytest-balance)](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.