Compare commits

..

135 Commits

Author SHA1 Message Date
Bruno Oliveira
f5b4a5d6ed Configure 'test' workflow to cancel running jobs for the branch 2023-05-12 17:33:18 -03:00
Bruno Oliveira
657ba9ac7f Only test on-push for master and a specific branch name
This avoids testing twice when pushing to the main repository, once for the push, and another for a PR.
2023-05-12 17:33:18 -03:00
Bruno Oliveira
fa08e0987b Add check-package step also to 'test' workflow
This ensures we do not have any surprises when we try to deploy,
which uses the same step to generate the package.
2023-05-12 17:33:18 -03:00
Bruno Oliveira
103ee05f01 Release 3.3.0 2023-05-12 12:24:43 -03:00
Bruno Oliveira
37b9dbdbf4 Deploy via a protected environment and using PyPI trusted publishers (#902)
Following recent discussions, this changes the development process as follows:

1. The deploy is now manually triggered after the release PR is approved.
2. The deploy workflow tags the repository only after the package has been published to PyPI.
3. Use PyPI trusted publishers instead of API tokens.


Co-authored-by: Ran Benita <ran@unusedvar.com>
2023-05-12 12:17:55 -03:00
Bruno Oliveira
be1d5c401f Make collection progress output less verbose (#901)
Fix #555
2023-05-12 10:36:32 -03:00
Ran Benita
e48dc3c6ad Merge pull request #900 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-05-10 14:48:21 +03:00
pre-commit-ci[bot]
a338075c0b [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.3.2 → v3.4.0](https://github.com/asottile/pyupgrade/compare/v3.3.2...v3.4.0)
2023-05-09 05:16:48 +00:00
pre-commit-ci[bot]
8fbecd8a23 [pre-commit.ci] pre-commit autoupdate (#897)
updates:
- [github.com/PyCQA/autoflake: v2.1.0 → v2.1.1](https://github.com/PyCQA/autoflake/compare/v2.1.0...v2.1.1)
- [github.com/asottile/pyupgrade: v3.3.1 → v3.3.2](https://github.com/asottile/pyupgrade/compare/v3.3.1...v3.3.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-04-25 07:37:20 -03:00
pre-commit-ci[bot]
6317cc32fa [pre-commit.ci] pre-commit autoupdate (#896)
updates:
- [github.com/PyCQA/autoflake: v2.0.2 → v2.1.0](https://github.com/PyCQA/autoflake/compare/v2.0.2...v2.1.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-04-21 15:15:53 -03:00
pre-commit-ci[bot]
4bed067b06 [pre-commit.ci] pre-commit autoupdate (#895)
updates:
- [github.com/pre-commit/mirrors-mypy: v1.1.1 → v1.2.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.1.1...v1.2.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-04-11 07:55:12 -03:00
Robert Schweizer
982ef0097a Improve docs of maxschedchunk (#893) 2023-04-06 09:46:34 -03:00
pre-commit-ci[bot]
c7d86097e9 [pre-commit.ci] pre-commit autoupdate (#892)
updates:
- [github.com/psf/black: 23.1.0 → 23.3.0](https://github.com/psf/black/compare/23.1.0...23.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-04-04 09:03:28 -03:00
pre-commit-ci[bot]
6719dfd6f8 [pre-commit.ci] pre-commit autoupdate (#889)
updates:
- [github.com/PyCQA/autoflake: v2.0.1 → v2.0.2](https://github.com/PyCQA/autoflake/compare/v2.0.1...v2.0.2)
- [github.com/pre-commit/mirrors-mypy: v1.0.1 → v1.1.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.0.1...v1.1.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-03-14 07:52:23 -03:00
Aleksandr Mezin
6e7bbfae37 Merge pull request #888 from amezin/release-3.2.1
Release 3.2.1
2023-03-12 17:11:47 +02:00
Aleksandr Mezin
b591be4868 Release 3.2.1
Some checks failed
deploy / deploy (push) Failing after 1s
2023-03-12 14:42:09 +02:00
Aleksandr Mezin
b6c195a56c Merge pull request #885 from amezin/steal-hang-fix
Fix hang caused by `steal` command with empty test queue
2023-03-10 13:12:30 +02:00
Aleksandr Mezin
6abcdfc22e Fix hang caused by steal command with empty test queue
Fixes #884
2023-03-09 16:44:34 +02:00
pre-commit-ci[bot]
58fd7ccc05 [pre-commit.ci] pre-commit autoupdate (#881)
updates:
- [github.com/pre-commit/mirrors-mypy: v1.0.0 → v1.0.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.0.0...v1.0.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-02-27 08:53:38 -03:00
Bruno Oliveira
ba526fad5a Merge pull request #879 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-02-14 07:29:00 -03:00
pre-commit-ci[bot]
efe674b265 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-mypy: v0.991 → v1.0.0](https://github.com/pre-commit/mirrors-mypy/compare/v0.991...v1.0.0)
2023-02-14 03:23:39 +00:00
Bruno Oliveira
5d692a7d63 Merge pull request #878 from akx/patch-1
docs: Remove unused statement in one-log-per-worker example
2023-02-13 11:01:58 -03:00
Aarni Koskela
5e795d88e7 docs: Remove unused statement in one-log-per-worker example 2023-02-13 15:38:59 +02:00
Aleksandr Mezin
2329d3454f Merge pull request #875 from pytest-dev/release-3.2.0
Release 3.2.0
2023-02-07 17:08:32 +02:00
Aleksandr Mezin
5c065198e9 Release 3.2.0
Some checks failed
deploy / deploy (push) Failing after 2s
Fixes #874
2023-02-07 16:46:47 +02:00
pre-commit-ci[bot]
c695763e92 [pre-commit.ci] pre-commit autoupdate (#869)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/PyCQA/autoflake: v2.0.0 → v2.0.1](https://github.com/PyCQA/autoflake/compare/v2.0.0...v2.0.1)
- [github.com/psf/black: 22.12.0 → 23.1.0](https://github.com/psf/black/compare/22.12.0...23.1.0)
- [github.com/asottile/blacken-docs: v1.12.1 → 1.13.0](https://github.com/asottile/blacken-docs/compare/v1.12.1...1.13.0)

* Update .pre-commit-config.yaml

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Bruno Oliveira <nicoddemus@gmail.com>
2023-02-07 07:58:55 -03:00
Bruno Oliveira
9373ddb3f8 Merge pull request #870 from hroncok/delenv
Tests: Unset PYTEST_XDIST_AUTO_NUM_WORKERS when the behavior without the envvar is asserted
2023-01-19 20:01:41 -03:00
Miro Hrončok
8fd1bfd182 Tests: Unset PYTEST_XDIST_AUTO_NUM_WORKERS when the behavior without the envvar is asserted 2023-01-17 20:10:12 +01:00
Thomas Kolar
017cc72b70 Document limitations for debugging as well as a workaround (#867)
Closes #863
2023-01-12 12:57:20 -03:00
Pat Thiel
cf19f76d86 Fix minor typos in the documentation (#866)
Some minor typo fixes.
2023-01-12 07:52:07 -03:00
Bruno Oliveira
691a0751bf Merge pull request #865 from amezin/fix-loadsched-tests
Fix some LoadScheduling tests
2023-01-11 20:34:27 -03:00
Aleksandr Mezin
e9860923bf Fix some LoadScheduling tests
The expected number of nodes didn't match throughout the test code.
2023-01-11 22:34:56 +02:00
Aleksandr Mezin
d1dfad3e92 Implement work-stealing scheduler (#862)
Closes #858
2023-01-11 08:38:57 -03:00
Aleksandr Mezin
9b0b5b1495 Add --maxschedchunk CLI option (#857)
Maximum number of tests scheduled in one step.

Setting it to 1 will force pytest to send tests to workers one by one -
might be useful for a small number of slow tests.

Larger numbers will allow the scheduler to submit consecutive chunks of tests
to workers - allows reusing fixtures.

Unlimited if not set.

Fixes #855
Fixes #255
2022-12-23 08:21:20 -03:00
pre-commit-ci[bot]
7faa69a04b [pre-commit.ci] pre-commit autoupdate (#856)
updates:
- [github.com/psf/black: 22.10.0 → 22.12.0](https://github.com/psf/black/compare/22.10.0...22.12.0)
- [github.com/asottile/pyupgrade: v3.3.0 → v3.3.1](https://github.com/asottile/pyupgrade/compare/v3.3.0...v3.3.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-12-13 08:14:04 -03:00
Ronny Pfannschmidt
7a7be87453 Merge pull request #854 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-12-06 10:47:31 +01:00
pre-commit-ci[bot]
d7877cef86 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.2.2 → v3.3.0](https://github.com/asottile/pyupgrade/compare/v3.2.2...v3.3.0)
2022-12-06 00:37:27 +00:00
Bruno Oliveira
639e0868f7 Merge pull request #852 from nicoddemus/release-3.1.0
Release 3.1.0
2022-12-03 11:33:06 -03:00
Bruno Oliveira
92a76bb013 Release 3.1.0
Some checks failed
deploy / deploy (push) Failing after 1s
2022-12-01 17:37:59 -03:00
Bruno Oliveira
6226965dac Merge pull request #851 from nicoddemus/789-default-dist-mode
Let users configure dist mode in the configuration file
2022-12-01 17:16:09 -03:00
Bruno Oliveira
7a0bc4c1c9 Let users configure dist mode in the configuration file
Fix #789
2022-11-30 13:57:48 -03:00
pre-commit-ci[bot]
c6bcd204ea [pre-commit.ci] pre-commit autoupdate (#849)
updates:
- [github.com/PyCQA/autoflake: v1.7.7 → v2.0.0](https://github.com/PyCQA/autoflake/compare/v1.7.7...v2.0.0)
- [github.com/pre-commit/pre-commit-hooks: v4.3.0 → v4.4.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.3.0...v4.4.0)
- [github.com/PyCQA/flake8: 5.0.4 → 6.0.0](https://github.com/PyCQA/flake8/compare/5.0.4...6.0.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-29 17:10:17 -03:00
Bread Genie
99c80c37a4 Fix typo psutils -> psutil (#848) 2022-11-24 09:50:25 -03:00
pre-commit-ci[bot]
e14895a96e [pre-commit.ci] pre-commit autoupdate (#846)
updates:
- [github.com/pre-commit/mirrors-mypy: v0.990 → v0.991](https://github.com/pre-commit/mirrors-mypy/compare/v0.990...v0.991)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-21 21:37:52 -03:00
Bruno Oliveira
bb27210cf9 Merge pull request #844 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-11-14 23:35:32 -03:00
Bruno Oliveira
4a33933e30 Use ternary operator to remove mypy error
Otherwise we would get this error:

```
error: Function "Callable[[Any, bool, Optional[float]], None]" could always be true in boolean context  [truthy-function]
```
2022-11-14 23:13:38 -03:00
pre-commit-ci[bot]
41620d2f46 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.2.0 → v3.2.2](https://github.com/asottile/pyupgrade/compare/v3.2.0...v3.2.2)
- [github.com/pre-commit/mirrors-mypy: v0.982 → v0.990](https://github.com/pre-commit/mirrors-mypy/compare/v0.982...v0.990)
2022-11-15 00:17:50 +00:00
Bruno Oliveira
6b6f133850 Merge pull request #842 from nicoddemus/drop-py36-add-py311
Drop Python 3.6 and add Python 3.11
2022-11-05 14:36:30 -03:00
Bruno Oliveira
0316baf3ec Add CHANGELOG for py36 drop and py311 support 2022-11-05 12:24:00 -03:00
Bruno Oliveira
aee850a121 Add official support for Python 3.11 2022-11-05 12:24:00 -03:00
Bruno Oliveira
66993ecada Split workflows into test and deploy 2022-11-05 12:24:00 -03:00
Bruno Oliveira
2ffb426178 Drop Python 3.6 and use Python 3.10 for secondary builds 2022-11-05 12:05:30 -03:00
Bruno Oliveira
d7d396a7f9 Run pyupgrade --py37-plus 2022-11-05 12:01:07 -03:00
Thomas Grainger
59457cc1bd Update pre-commit
Also:

* Configure flake8 to ignore formatting-related problems (let black worry about them).
* Fix reference to Python 3.10 in CI,

Co-authored-by: Bruno Oliveira <nicoddemus@gmail.com>
2022-11-05 11:58:57 -03:00
Bruno Oliveira
e232b7886a Merge pull request #838 from pytest-dev/hroncok-patch-1
Remove deprecated stuff from the package description
2022-10-28 13:52:42 -03:00
Miro Hrončok
873c1fdd5e Remove deprecated stuff from the package description
Fixes https://github.com/pytest-dev/pytest-xdist/issues/835
2022-10-28 16:29:45 +02:00
Bruno Oliveira
7949c14f67 Merge pull request #830 from nicoddemus/release-3.0.0
Release 3.0.2
2022-10-25 12:58:44 -03:00
Bruno Oliveira
eed37d4771 Update CHANGELOG
Some checks failed
build / build (ubuntu-latest, 3.10-dev, py310-pytestlatest) (push) Failing after 1s
build / build (ubuntu-latest, 3.6, py36-pytestlatest) (push) Failing after 1s
build / build (ubuntu-latest, 3.7, py37-pytestlatest) (push) Failing after 1s
build / build (ubuntu-latest, 3.8, py38-psutil) (push) Failing after 1s
build / build (ubuntu-latest, 3.8, py38-pytestlatest) (push) Failing after 1s
build / build (ubuntu-latest, 3.8, py38-pytestmain) (push) Failing after 1s
build / build (ubuntu-latest, 3.8, py38-setproctitle) (push) Failing after 1s
build / build (ubuntu-latest, 3.9, py39-pytestlatest) (push) Failing after 1s
build / build (windows-latest, 3.10-dev, py310-pytestlatest) (push) Has been cancelled
build / build (windows-latest, 3.6, py36-pytestlatest) (push) Has been cancelled
build / build (windows-latest, 3.7, py37-pytestlatest) (push) Has been cancelled
build / build (windows-latest, 3.8, py38-psutil) (push) Has been cancelled
build / build (windows-latest, 3.8, py38-pytestlatest) (push) Has been cancelled
build / build (windows-latest, 3.8, py38-pytestmain) (push) Has been cancelled
build / build (windows-latest, 3.8, py38-setproctitle) (push) Has been cancelled
build / build (windows-latest, 3.9, py39-pytestlatest) (push) Has been cancelled
build / deploy (push) Has been cancelled
2022-10-25 12:36:11 -03:00
Bruno Oliveira
3e9284b6e0 Merge remote-tracking branch 'upstream/master' into release-3.0.0 2022-10-25 12:35:32 -03:00
Bruno Oliveira
ccdab727a2 Merge pull request #831 from pytest-dev/fix-setup
fix and update packaging
2022-10-25 12:32:31 -03:00
Ronny Pfannschmidt
0c981d3547 fix and update packaging
* drop empty setup.py which "breaks" on incomplete build envs
* use the build package to build the dist
* update the gh workflow to use it

fixup noise from pre-commit
2022-10-25 17:07:47 +02:00
Bruno Oliveira
38dcf52ae6 Release 3.0.1
Some checks failed
build / build (ubuntu-latest, 3.10-dev, py310-pytestlatest) (push) Failing after 1s
build / build (ubuntu-latest, 3.6, py36-pytestlatest) (push) Failing after 1s
build / build (ubuntu-latest, 3.7, py37-pytestlatest) (push) Failing after 1s
build / build (ubuntu-latest, 3.8, py38-psutil) (push) Failing after 1s
build / build (ubuntu-latest, 3.8, py38-pytestlatest) (push) Failing after 1s
build / build (ubuntu-latest, 3.8, py38-pytestmain) (push) Failing after 1s
build / build (ubuntu-latest, 3.8, py38-setproctitle) (push) Failing after 1s
build / build (ubuntu-latest, 3.9, py39-pytestlatest) (push) Failing after 2s
build / build (windows-latest, 3.10-dev, py310-pytestlatest) (push) Has been cancelled
build / build (windows-latest, 3.6, py36-pytestlatest) (push) Has been cancelled
build / build (windows-latest, 3.7, py37-pytestlatest) (push) Has been cancelled
build / build (windows-latest, 3.8, py38-psutil) (push) Has been cancelled
build / build (windows-latest, 3.8, py38-pytestlatest) (push) Has been cancelled
build / build (windows-latest, 3.8, py38-pytestmain) (push) Has been cancelled
build / build (windows-latest, 3.8, py38-setproctitle) (push) Has been cancelled
build / build (windows-latest, 3.9, py39-pytestlatest) (push) Has been cancelled
build / deploy (push) Has been cancelled
2022-10-25 11:10:07 -03:00
Bruno Oliveira
f2633f1b37 Fetch all tags, as required by setuptools-scm 2022-10-25 11:09:53 -03:00
Bruno Oliveira
192193197b Update CHANGELOG for 3.0
Some checks failed
build / build (ubuntu-latest, 3.10-dev, py310-pytestlatest) (push) Failing after 3s
build / build (ubuntu-latest, 3.6, py36-pytestlatest) (push) Failing after 2s
build / build (ubuntu-latest, 3.7, py37-pytestlatest) (push) Failing after 3s
build / build (ubuntu-latest, 3.8, py38-psutil) (push) Failing after 2s
build / build (ubuntu-latest, 3.8, py38-pytestlatest) (push) Failing after 2s
build / build (ubuntu-latest, 3.8, py38-pytestmain) (push) Failing after 2s
build / build (ubuntu-latest, 3.8, py38-setproctitle) (push) Failing after 3s
build / build (ubuntu-latest, 3.9, py39-pytestlatest) (push) Failing after 3s
build / build (windows-latest, 3.10-dev, py310-pytestlatest) (push) Has been cancelled
build / build (windows-latest, 3.6, py36-pytestlatest) (push) Has been cancelled
build / build (windows-latest, 3.7, py37-pytestlatest) (push) Has been cancelled
build / build (windows-latest, 3.8, py38-psutil) (push) Has been cancelled
build / build (windows-latest, 3.8, py38-pytestlatest) (push) Has been cancelled
build / build (windows-latest, 3.8, py38-pytestmain) (push) Has been cancelled
build / build (windows-latest, 3.8, py38-setproctitle) (push) Has been cancelled
build / build (windows-latest, 3.9, py39-pytestlatest) (push) Has been cancelled
build / deploy (push) Has been cancelled
2022-10-24 13:44:11 -03:00
Bruno Oliveira
0f58a14def Fix towncrier command for latest version 2022-10-24 13:43:03 -03:00
Bruno Oliveira
bd23c2460f Fix changelog fragments 2022-10-24 13:43:03 -03:00
Bruno Oliveira
794f28bcf4 Use modern syntax for towncrier 2022-10-24 13:43:03 -03:00
Bruno Oliveira
31e80c8c6a Merge pull request #828 from nicoddemus/deprecate-rsync-825
Deprecate rsync support
2022-10-24 13:02:58 -03:00
Bruno Oliveira
1e9f6a7c3c Fix type annotation for pytest 6.2 2022-10-24 12:03:14 -03:00
Bruno Oliveira
ed65e888cf Deprecate rsync support
Fix #825
2022-10-24 11:53:15 -03:00
Petr Viktorin
c168706fbe Get -nauto default from PYTEST_XDIST_AUTO_NUM_WORKERS (#829)
* Get `-nauto` default from `PYTEST_XDIST_AUTO_NUM_WORKERS`

A few additional tests for existing functionality are added too.
And the ``-n logical`` option is documented.

Fixes: https://github.com/pytest-dev/pytest-xdist/issues/792
2022-10-24 09:33:19 -03:00
Bruno Oliveira
ab95a4b45d Merge pull request #827 from nicoddemus/deprecate-looponfail
Deprecate looponfail plugin
2022-10-22 13:31:06 -03:00
Bruno Oliveira
8b0856a986 Deprecate looponfail plugin
Fix #826
2022-10-22 10:43:02 -03:00
Bruno Oliveira
f2064873fd Merge pull request #821 from nicoddemus/drop-forked
Remove --boxed and pytest-forked dependency
2022-10-22 10:04:40 -03:00
Bruno Oliveira
1fbf49909f Remove --boxed and pytest-forked dependency
Fixes #468
Fixes #543
2022-10-22 07:31:34 -03:00
Anthony Sottile
1471164197 Replace usages of py.log by custom implementation (#822)
Co-authored-by: Bruno Oliveira <nicoddemus@gmail.com>
2022-10-22 07:26:01 -03:00
Aleksandr Mezin
80130d59ea Restore old initial batch distribution logic in LoadScheduling (#812)
* Restore old initial batch distribution logic in LoadScheduling

pytest orders tests for optimal sequential execution - i. e. avoiding
unnecessary setup and teardown of fixtures. So executing tests in consecutive
chunks is important for optimal performance.

Commit 09d79ace35 optimized test distribution for
the corner case, when the number of tests is less than 2 * number of nodes.
At the same time, it made initial test distribution worse for all other cases.
If some tests use some fixture, and these tests fit into the initial batch,
the fixture will be created min(n_tests, n_workers) times, no matter how many
other tests there are. With the old algorithm (before
09d79ace35), if there are enough tests not using
the fixture, the fixture was created only once.

So restore the old behavior for typical cases where the number of tests is
much greater than the number of workers (or, strictly speaking, when there
are at least 2 tests for every node).

In my test suite, where fixtures create Docker containers, this change reduces
total run time by 10-15%.

This is a partial revert of commit 09d79ace35

Co-authored-by: Bruno Oliveira <nicoddemus@gmail.com>
2022-10-22 07:12:16 -03:00
Bruno Oliveira
a1785b539c Merge pull request #824 from nicoddemus/drop-py-path
Replace py.path.local usages by pathlib.Path
2022-10-22 07:00:42 -03:00
Bruno Oliveira
25cc1a4119 Replace py.path.local usages by pathlib.Path 2022-10-21 22:54:17 -03:00
Anthony Sottile
f04cd22fd6 Merge pull request #823 from pytest-dev/remove-pydir
remove py._pydir as rsync candidate
2022-10-21 20:38:22 -04:00
Anthony Sottile
42024f374a remove py._pydir as rsync candidate 2022-10-21 16:09:14 -07:00
Aleksandr Mezin
e407a3377e Cancel shutdown when crashed worker is restarted (#813)
Co-authored-by: Bruno Oliveira <nicoddemus@gmail.com>
2022-09-06 17:37:38 -03:00
Bruno Oliveira
6169d494a4 Merge pull request #516 from blueyed/test_collection_crash 2022-09-06 17:29:28 -03:00
Daniel Hahler
d137384315 Add test_collection_crash
Reproduces:

    INTERNALERROR> TypeError: unhashable type: 'ExceptionChainRepr'

Ref: https://github.com/pytest-dev/pytest-xdist/issues/515
Ref: https://github.com/pytest-dev/pytest/pull/6988
2022-09-06 17:20:44 -03:00
Bruno Oliveira
23992d874e Merge pull request #807 from pytest-dev/no-capture-docs 2022-09-06 17:20:20 -03:00
Bruno Oliveira
2ec4f407f5 Add known limitation about -s not working 2022-08-24 12:52:36 -03:00
Sylvain Marié
9236f11757 Added a couple tests for #404 (#766)
* Added a couple tests for #404
Co-authored-by: Sylvain MARIE <sylvain.marie@se.com>
2022-08-23 21:41:23 +01:00
Pepa
61132777f8 Added known limitation section to the docs (#796)
Co-authored-by: Josef <sorry.i-keep@it.private>
Co-authored-by: Bruno Oliveira <nicoddemus@gmail.com>
2022-07-22 10:44:17 -03:00
Bruno Oliveira
8616ff368c Merge pull request #793 from pytest-dev/fix-setuptools-scm 2022-07-13 15:55:17 -03:00
Petr Viktorin
1a5e680e91 Document the pytest_xdist_auto_num_workers hook (#791) 2022-07-13 15:54:26 -03:00
Ronny Pfannschmidt
9d69c1a7da correct setuptools_scm support
modern setuptools_scm drops python 3.6

* use build isolation
* drop setup_requires
2022-07-12 16:54:51 +02:00
Bruno Oliveira
8a417f4545 Merge pull request #785 from pytest-dev/pre-commit-ci-update-config 2022-05-31 08:05:58 -03:00
pre-commit-ci[bot]
44cbd7fdc5 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-mypy: v0.950 → v0.960](https://github.com/pre-commit/mirrors-mypy/compare/v0.950...v0.960)
2022-05-30 21:01:27 +00:00
Bruno Oliveira
37c06dae24 Merge pull request #782 from pytest-dev/pre-commit-ci-update-config 2022-05-10 19:14:57 -03:00
pre-commit-ci[bot]
2220ccecb1 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.32.0 → v2.32.1](https://github.com/asottile/pyupgrade/compare/v2.32.0...v2.32.1)
2022-05-09 20:58:23 +00:00
pre-commit-ci[bot]
7743e14fd4 [pre-commit.ci] pre-commit autoupdate (#781)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-05-04 09:21:39 -03:00
auziel
8652ef91e6 Fix the example of using --rsyncdir option (#777)
Co-authored-by: Ronny Pfannschmidt <opensource@ronnypfannschmidt.de>
Co-authored-by: Bruno Oliveira <nicoddemus@gmail.com>
2022-04-20 18:00:28 -03:00
Bruno Oliveira
e526c38311 Merge pull request #776 from pytest-dev/pre-commit-ci-update-config 2022-04-12 08:05:34 -03:00
pre-commit-ci[bot]
8723495e9b [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.1.0 → v4.2.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.1.0...v4.2.0)
- [github.com/asottile/pyupgrade: v2.31.1 → v2.32.0](https://github.com/asottile/pyupgrade/compare/v2.31.1...v2.32.0)
2022-04-11 21:29:34 +00:00
Bruno Oliveira
48a213b47c Merge pull request #774 from pytest-dev/pre-commit-ci-update-config 2022-04-04 18:18:41 -03:00
pre-commit-ci[bot]
eca7f202b6 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 22.1.0 → 22.3.0](https://github.com/psf/black/compare/22.1.0...22.3.0)
2022-04-04 20:28:36 +00:00
Ronny Pfannschmidt
3fc253f192 Merge pull request #770 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-03-28 22:02:14 +02:00
pre-commit-ci[bot]
6fca8686f5 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-mypy: v0.941 → v0.942](https://github.com/pre-commit/mirrors-mypy/compare/v0.941...v0.942)
2022-03-28 19:38:44 +00:00
Bruno Oliveira
9c7b5f4831 Merge pull request #768 from pytest-dev/pre-commit-ci-update-config 2022-03-22 09:09:23 -03:00
pre-commit-ci[bot]
2fefa36db6 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-mypy: v0.940 → v0.941](https://github.com/pre-commit/mirrors-mypy/compare/v0.940...v0.941)
2022-03-21 23:42:02 +00:00
Bruno Oliveira
1102b82795 Merge pull request #765 from pytest-dev/pre-commit-ci-update-config 2022-03-15 16:39:52 -03:00
pre-commit-ci[bot]
a685a04bd1 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/blacken-docs: v1.12.0 → v1.12.1](https://github.com/asottile/blacken-docs/compare/v1.12.0...v1.12.1)
- [github.com/asottile/pyupgrade: v2.31.0 → v2.31.1](https://github.com/asottile/pyupgrade/compare/v2.31.0...v2.31.1)
- [github.com/pre-commit/mirrors-mypy: v0.931 → v0.940](https://github.com/pre-commit/mirrors-mypy/compare/v0.931...v0.940)
2022-03-14 23:27:19 +00:00
Bruno Oliveira
41a4d439c8 Fix typo in docs 2022-03-10 12:24:32 -03:00
Bruno Oliveira
24f331ccf0 Merge pull request #764 from nicoddemus/fix-log-worker 2022-03-10 11:55:19 -03:00
Bruno Oliveira
34f63600ee Simplify the example about creating one log per worker
No need to use a custom option as this complicates the example a bit and might distract from the main details.
2022-03-10 11:52:07 -03:00
Bruno Oliveira
28688962e1 Merge pull request #762 from marcosanchotene/multiple-log-files 2022-03-10 09:38:24 -03:00
Bruno Oliveira
7f04ce35c9 Add blacken-docs and use autodoc 2022-03-10 09:33:23 -03:00
Bruno Oliveira
ad5468b6e7 Add blacken docs pre-commit hook 2022-03-10 09:26:16 -03:00
Bruno Oliveira
797867f23d Simplify example and use syntax for env vars 2022-03-10 09:25:37 -03:00
Bruno Oliveira
e3c1a3de67 Remove redundant type casts 2022-03-10 09:11:00 -03:00
Marco Sanchotene
14e1d80f10 Add instructions to create one file for each worker 2022-03-08 19:11:48 -03:00
Bruno Oliveira
29c4cce1d4 Merge pull request #757 from kianmeng/fix-typos 2022-02-07 20:29:59 -03:00
Kian-Meng Ang
d79ef915a2 Fix typos 2022-02-07 21:59:38 +08:00
pre-commit-ci[bot]
290b322a5d [pre-commit.ci] pre-commit autoupdate (#755)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-02-01 08:34:13 -03:00
Ran Benita
8fcf927c44 Merge pull request #748 from bluetech/changelog
CHANGELOG: add `--boxed` deprecation to 2.5.0
2022-01-31 15:30:33 +02:00
Bruno Oliveira
8cd10225df Add sphinx docs folder and changelog URLs to metadata (#751) 2022-01-29 16:29:36 +00:00
Bruno Oliveira
1a5ad8ac20 Merge pull request #750 from ssbarnea/fix/maxprocesses 2022-01-29 08:04:12 -03:00
Sorin Sbarnea
dd61dce892 Document two missing arguments
Fixes: #614
2022-01-29 10:47:05 +00:00
Ran Benita
2aeb5d51f5 CHANGELOG: add --boxed deprecation to 2.5.0 2022-01-24 16:14:24 +02:00
Bruno Oliveira
a1a6c77028 Merge pull request #746 from pytest-dev/pre-commit-ci-update-config 2022-01-11 07:22:27 -03:00
pre-commit-ci[bot]
628a04de8e [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-mypy: v0.930 → v0.931](https://github.com/pre-commit/mirrors-mypy/compare/v0.930...v0.931)
2022-01-10 21:28:28 +00:00
Bruno Oliveira
5749a347b9 Merge pull request #745 from pytest-dev/pre-commit-ci-update-config 2022-01-04 09:24:43 -03:00
pre-commit-ci[bot]
c4e0d39f21 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.29.1 → v2.31.0](https://github.com/asottile/pyupgrade/compare/v2.29.1...v2.31.0)
2022-01-03 21:09:27 +00:00
Bruno Oliveira
d709e4cda0 Merge pull request #744 from pytest-dev/pre-commit-ci-update-config 2021-12-27 19:00:50 -03:00
pre-commit-ci[bot]
1c7fc1a812 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.0.1 → v4.1.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.0.1...v4.1.0)
- [github.com/pre-commit/mirrors-mypy: v0.920 → v0.930](https://github.com/pre-commit/mirrors-mypy/compare/v0.920...v0.930)
2021-12-27 21:13:26 +00:00
Bruno Oliveira
6b02b353d2 Merge pull request #743 from pytest-dev/pre-commit-ci-update-config 2021-12-27 09:29:49 -03:00
Bruno Oliveira
18143275c6 Remove obsolete skip based on java (no longer supported) 2021-12-27 08:05:46 -03:00
Bruno Oliveira
d21f59fea7 Fix typing errors after updating mypy 2021-12-27 08:05:01 -03:00
pre-commit-ci[bot]
82ecdea0c2 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-mypy: v0.910-1 → v0.920](https://github.com/pre-commit/mirrors-mypy/compare/v0.910-1...v0.920)
2021-12-20 20:42:50 +00:00
Bruno Oliveira
f9f8a765ba Merge pull request #741 from nicoddemus/release-2.5.0 2021-12-10 08:45:48 -03:00
46 changed files with 2562 additions and 967 deletions

47
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: deploy
on:
workflow_dispatch:
inputs:
version:
description: 'Release version'
required: true
default: '1.2.3'
jobs:
package:
runs-on: ubuntu-latest
env:
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ github.event.inputs.version }}
steps:
- uses: actions/checkout@v3
- name: Build and Check Package
uses: hynek/build-and-inspect-python-package@v1.5
deploy:
needs: package
runs-on: ubuntu-latest
environment: deploy
permissions:
id-token: write # For PyPI trusted publishers.
contents: write # For tag.
steps:
- uses: actions/checkout@v3
- name: Download Package
uses: actions/download-artifact@v3
with:
name: Packages
path: dist
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@v1.8.5
- name: Push tag
run: |
git tag --annotate --message=v${{ github.event.inputs.version }} v${{ github.event.inputs.version }} ${{ github.sha }}
git push origin v${{ github.event.inputs.version }}

View File

@@ -1,81 +0,0 @@
name: build
on: [push, pull_request]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
tox_env:
- "py36-pytestlatest"
- "py37-pytestlatest"
- "py38-pytestlatest"
- "py39-pytestlatest"
- "py310-pytestlatest"
- "py38-pytestmain"
- "py38-psutil"
- "py38-setproctitle"
os: [ubuntu-latest, windows-latest]
include:
- tox_env: "py36-pytestlatest"
python: "3.6"
- tox_env: "py37-pytestlatest"
python: "3.7"
- tox_env: "py38-pytestlatest"
python: "3.8"
- tox_env: "py39-pytestlatest"
python: "3.9"
- tox_env: "py310-pytestlatest"
python: "3.10-dev"
- tox_env: "py38-pytestmain"
python: "3.8"
- tox_env: "py38-psutil"
python: "3.8"
- tox_env: "py38-setproctitle"
python: "3.8"
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python }}
- name: Install tox
run: |
python -m pip install --upgrade pip
pip install tox
- name: Test
run: |
tox -e ${{ matrix.tox_env }}
deploy:
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags')
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: "3.7"
- name: Install wheel
run: |
python -m pip install --upgrade pip
pip install wheel
- name: Build package
run: |
python setup.py sdist bdist_wheel
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@master
with:
user: __token__
password: ${{ secrets.pypi_token }}

81
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: test
on:
push:
branches:
- master
- "test-me-*"
pull_request:
branches:
- "*"
# Cancel running jobs for the same workflow and branch.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check-package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build and Check Package
uses: hynek/build-and-inspect-python-package@v1.5
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
tox_env:
- "py37-pytestlatest"
- "py38-pytestlatest"
- "py39-pytestlatest"
- "py310-pytestlatest"
- "py310-pytestmain"
- "py311-pytestlatest"
- "py311-pytestmain"
- "py310-psutil"
- "py310-setproctitle"
os: [ubuntu-latest, windows-latest]
include:
- tox_env: "py37-pytestlatest"
python: "3.7"
- tox_env: "py38-pytestlatest"
python: "3.8"
- tox_env: "py39-pytestlatest"
python: "3.9"
- tox_env: "py310-pytestlatest"
python: "3.10"
- tox_env: "py310-pytestmain"
python: "3.10"
- tox_env: "py311-pytestlatest"
python: "3.11"
- tox_env: "py311-pytestmain"
python: "3.11"
- tox_env: "py310-psutil"
python: "3.10"
- tox_env: "py310-setproctitle"
python: "3.10"
steps:
- uses: actions/checkout@v3
with:
# Needed to fetch tags, which are required by setuptools-scm.
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python }}
- name: Install tox
run: |
python -m pip install --upgrade pip
pip install tox
- name: Test
run: |
tox -e ${{ matrix.tox_env }}

View File

@@ -1,22 +1,32 @@
repos: repos:
- repo: https://github.com/PyCQA/autoflake
rev: v2.1.1
hooks:
- id: autoflake
args: ["--in-place", "--remove-unused-variables", "--remove-all-unused-imports"]
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 21.12b0 rev: 23.3.0
hooks: hooks:
- id: black - id: black
args: [--safe, --quiet, --target-version, py35] args: [--safe, --quiet, --target-version, py35]
- repo: https://github.com/asottile/blacken-docs
rev: 1.13.0
hooks:
- id: blacken-docs
additional_dependencies: [black==23.1.0]
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1 rev: v4.4.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: debug-statements - id: debug-statements
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
rev: 4.0.1 rev: 6.0.0
hooks: hooks:
- id: flake8 - id: flake8
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v2.29.1 rev: v3.4.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py3-plus] args: [--py3-plus]
@@ -29,7 +39,7 @@ repos:
language: python language: python
additional_dependencies: [pygments, restructuredtext_lint] additional_dependencies: [pygments, restructuredtext_lint]
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.910-1 rev: v1.2.0
hooks: hooks:
- id: mypy - id: mypy
files: ^(src/|testing/) files: ^(src/|testing/)

View File

@@ -1,6 +1,154 @@
pytest-xdist 3.3.0 (2023-05-12)
===============================
Features
--------
- `#555 <https://github.com/pytest-dev/pytest-xdist/issues/555>`_: Improved progress output when collecting nodes to be less verbose.
pytest-xdist 3.2.1 (2023-03-12)
===============================
Bug Fixes
---------
- `#884 <https://github.com/pytest-dev/pytest-xdist/issues/884>`_: Fixed hang in ``worksteal`` scheduler.
pytest-xdist 3.2.0 (2023-02-07)
===============================
Improved Documentation
----------------------
- `#863 <https://github.com/pytest-dev/pytest-xdist/issues/863>`_: Document limitations for debugging due to standard I/O of workers not being forwarded. Also, mention remote debugging as a possible workaround.
Features
--------
- `#855 <https://github.com/pytest-dev/pytest-xdist/issues/855>`_: Users can now configure ``load`` scheduling precision using ``--maxschedchunk`` command
line option.
- `#858 <https://github.com/pytest-dev/pytest-xdist/issues/858>`_: New ``worksteal`` scheduler, based on the idea of `work stealing <https://en.wikipedia.org/wiki/Work_stealing>`_. It's similar to ``load`` scheduler, but it should handle tests with significantly differing duration better, and, at the same time, it should provide similar or better reuse of fixtures.
Trivial Changes
---------------
- `#870 <https://github.com/pytest-dev/pytest-xdist/issues/870>`_: Make the tests pass even when ``$PYTEST_XDIST_AUTO_NUM_WORKERS`` is set.
pytest-xdist 3.1.0 (2022-12-01)
===============================
Features
--------
- `#789 <https://github.com/pytest-dev/pytest-xdist/issues/789>`_: Users can now set a default distribution mode in their configuration file:
.. code-block:: ini
[pytest]
addopts = --dist loadscope
- `#842 <https://github.com/pytest-dev/pytest-xdist/issues/842>`_: Python 3.11 is now officially supported.
Removals
--------
- `#842 <https://github.com/pytest-dev/pytest-xdist/issues/842>`_: Python 3.6 is no longer supported.
pytest-xdist 3.0.2 (2022-10-25)
===============================
Bug Fixes
---------
- `#813 <https://github.com/pytest-dev/pytest-xdist/issues/813>`_: Cancel shutdown when a crashed worker is restarted.
Deprecations
------------
- `#825 <https://github.com/pytest-dev/pytest-xdist/issues/825>`_: The ``--rsyncdir`` command line argument and ``rsyncdirs`` config variable are deprecated.
The rsync feature will be removed in pytest-xdist 4.0.
- `#826 <https://github.com/pytest-dev/pytest-xdist/issues/826>`_: The ``--looponfail`` command line argument and ``looponfailroots`` config variable are deprecated.
The loop-on-fail feature will be removed in pytest-xdist 4.0.
Improved Documentation
----------------------
- `#791 <https://github.com/pytest-dev/pytest-xdist/issues/791>`_: Document the ``pytest_xdist_auto_num_workers`` hook.
- `#796 <https://github.com/pytest-dev/pytest-xdist/issues/796>`_: Added known limitations section to documentation.
- `#829 <https://github.com/pytest-dev/pytest-xdist/issues/829>`_: Document the ``-n logical`` option.
Features
--------
- `#792 <https://github.com/pytest-dev/pytest-xdist/issues/792>`_: The environment variable ``PYTEST_XDIST_AUTO_NUM_WORKERS`` can now be used to
specify the default for ``-n auto`` and ``-n logical``.
- `#812 <https://github.com/pytest-dev/pytest-xdist/issues/812>`_: Partially restore old initial batch distribution algorithm in ``LoadScheduling``.
pytest orders tests for optimal sequential execution - i. e. avoiding
unnecessary setup and teardown of fixtures. So executing tests in consecutive
chunks is important for optimal performance.
In v1.14, initial test distribution in ``LoadScheduling`` was changed to
round-robin, optimized for the corner case, when the number of tests is less
than ``2 * number of nodes``. At the same time, it became worse for all other
cases.
For example: if some tests use some "heavy" fixture, and these tests fit into
the initial batch, with round-robin distribution the fixture will be created
``min(n_tests, n_workers)`` times, no matter how many other tests there are.
With the old algorithm (before v1.14), if there are enough tests not using
the fixture, the fixture was created only once.
So restore the old behavior for typical cases where the number of tests is
much greater than the number of workers (or, strictly speaking, when there
are at least 2 tests for every node).
Removals
--------
- `#468 <https://github.com/pytest-dev/pytest-xdist/issues/468>`_: The ``--boxed`` command-line option has been removed. If you still need this functionality, install `pytest-forked <https://pypi.org/project/pytest-forked>`__ separately.
Trivial Changes
---------------
- `#468 <https://github.com/pytest-dev/pytest-xdist/issues/468>`_: The ``py`` dependency has been dropped.
- `#822 <https://github.com/pytest-dev/pytest-xdist/issues/822>`_: Replace internal usage of ``py.log`` with a custom solution (but with the same interface).
- `#823 <https://github.com/pytest-dev/pytest-xdist/issues/823>`_: Remove usage of ``py._pydir`` as an rsync candidate.
- `#824 <https://github.com/pytest-dev/pytest-xdist/issues/824>`_: Replace internal usages of ``py.path.local`` by ``pathlib.Path``.
pytest-xdist 2.5.0 (2021-12-10) pytest-xdist 2.5.0 (2021-12-10)
=============================== ===============================
Deprecations and Removals
-------------------------
- `#468 <https://github.com/pytest-dev/pytest-xdist/issues/468>`_: The ``--boxed`` command line argument is deprecated. Install `pytest-forked <https://pypi.org/project/pytest-forked>`__ and use ``--forked`` instead. pytest-xdist 3.0.0 will remove the ``--boxed`` argument and ``pytest-forked`` dependency.
Features Features
-------- --------

View File

@@ -13,7 +13,7 @@ pytest-xdist
:alt: Python versions :alt: Python versions
:target: https://pypi.python.org/pypi/pytest-xdist :target: https://pypi.python.org/pypi/pytest-xdist
.. image:: https://github.com/pytest-dev/pytest-xdist/workflows/build/badge.svg .. image:: https://github.com/pytest-dev/pytest-xdist/workflows/test/badge.svg
:target: https://github.com/pytest-dev/pytest-xdist/actions :target: https://github.com/pytest-dev/pytest-xdist/actions
.. image:: https://img.shields.io/badge/code%20style-black-000000.svg .. image:: https://img.shields.io/badge/code%20style-black-000000.svg
@@ -25,520 +25,9 @@ tests across multiple CPUs to speed up test execution::
pytest -n auto pytest -n auto
With this call, pytest will spawn a number of workers processes equal to the number of available CPUs, and distribute With this call, pytest will spawn a number of workers processes equal to the number of available CPUs, and distribute
the tests randomly across them. There is also a number of `distribution modes`_ to choose from. the tests randomly across them.
**NOTE**: due to how pytest-xdist is implemented, the ``-s/--capture=no`` option does not work. Documentation
=============
.. contents:: **Table of Contents** Documentation is available at `Read The Docs <https://pytest-xdist.readthedocs.io>`__.
Installation
------------
Install the plugin with::
pip install pytest-xdist
To use ``psutil`` for detection of the number of CPUs available, install the ``psutil`` extra::
pip install pytest-xdist[psutil]
Features
--------
* Test run parallelization_: tests can be executed across multiple CPUs or hosts.
This allows to speed up development or to use special resources of `remote machines`_.
* ``--looponfail``: run your tests repeatedly in a subprocess. After each run
pytest waits until a file in your project changes and then re-runs
the previously failing tests. This is repeated until all tests pass
after which again a full run is performed.
* `Multi-Platform`_ coverage: you can specify different Python interpreters
or different platforms and run tests in parallel on all of them.
Before running tests remotely, ``pytest`` efficiently "rsyncs" your
program source code to the remote place.
You may specify different Python versions and interpreters. It does not
installs/synchronize dependencies however.
**Note**: this mode exists mostly for backward compatibility, as modern development
relies on continuous integration for multi-platform testing.
.. _parallelization:
Running tests across multiple CPUs
----------------------------------
To send tests to multiple CPUs, use the ``-n`` (or ``--numprocesses``) option::
pytest -n 8
Pass ``-n auto`` to use as many processes as your computer has CPU cores. This
can lead to considerable speed ups, especially if your test suite takes a
noticeable amount of time.
The test distribution algorithm is configured with the ``--dist`` command-line option:
.. _distribution modes:
* ``--dist load`` **(default)**: Sends pending tests to any worker that is
available, without any guaranteed order.
* ``--dist loadscope``: Tests are grouped by **module** for *test functions*
and by **class** for *test methods*. Groups are distributed to available
workers as whole units. This guarantees that all tests in a group run in the
same process. This can be useful if you have expensive module-level or
class-level fixtures. Grouping by class takes priority over grouping by
module.
* ``--dist loadfile``: Tests are grouped by their containing file. Groups are
distributed to available workers as whole units. This guarantees that all
tests in a file run in the same worker.
* ``--dist loadgroup``: Tests are grouped by the ``xdist_group`` mark. Groups are
distributed to available workers as whole units. This guarantees that all
tests with same ``xdist_group`` name run in the same worker.
.. code-block:: python
@pytest.mark.xdist_group(name="group1")
def test1():
pass
class TestA:
@pytest.mark.xdist_group("group1")
def test2():
pass
This will make sure ``test1`` and ``TestA::test2`` will run in the same worker.
Tests without the ``xdist_group`` mark are distributed normally as in the ``--dist=load`` mode.
* ``--dist no``: The normal pytest execution mode, runs one test at a time (no distribution at all).
Running tests in a Python subprocess
------------------------------------
To instantiate a ``python3.9`` subprocess and send tests to it, you may type::
pytest -d --tx popen//python=python3.9
This will start a subprocess which is run with the ``python3.9``
Python interpreter, found in your system binary lookup path.
If you prefix the --tx option value like this::
--tx 3*popen//python=python3.9
then three subprocesses would be created and tests
will be load-balanced across these three processes.
.. _boxed:
Running tests in a boxed subprocess
-----------------------------------
This functionality has been moved to the
`pytest-forked <https://github.com/pytest-dev/pytest-forked>`_ plugin, but the ``--boxed`` option
is still kept for backward compatibility.
.. _`remote machines`:
Sending tests to remote SSH accounts
------------------------------------
Suppose you have a package ``mypkg`` which contains some
tests that you can successfully run locally. And you
have a ssh-reachable machine ``myhost``. Then
you can ad-hoc distribute your tests by typing::
pytest -d --tx ssh=myhostpopen --rsyncdir mypkg mypkg
This will synchronize your :code:`mypkg` package directory
to a remote ssh account and then locally collect tests
and send them to remote places for execution.
You can specify multiple :code:`--rsyncdir` directories
to be sent to the remote side.
.. note::
For pytest to collect and send tests correctly
you not only need to make sure all code and tests
directories are rsynced, but that any test (sub) directory
also has an :code:`__init__.py` file because internally
pytest references tests as a fully qualified python
module path. **You will otherwise get strange errors**
during setup of the remote side.
You can specify multiple :code:`--rsyncignore` glob patterns
to be ignored when file are sent to the remote side.
There are also internal ignores: :code:`.*, *.pyc, *.pyo, *~`
Those you cannot override using rsyncignore command-line or
ini-file option(s).
Sending tests to remote Socket Servers
--------------------------------------
Download the single-module `socketserver.py`_ Python program
and run it like this::
python socketserver.py
It will tell you that it starts listening on the default
port. You can now on your home machine specify this
new socket host with something like this::
pytest -d --tx socket=192.168.1.102:8888 --rsyncdir mypkg mypkg
.. _`atonce`:
.. _`Multi-Platform`:
Running tests on many platforms at once
---------------------------------------
The basic command to run tests on multiple platforms is::
pytest --dist=each --tx=spec1 --tx=spec2
If you specify a windows host, an OSX host and a Linux
environment this command will send each tests to all
platforms - and report back failures from all platforms
at once. The specifications strings use the `xspec syntax`_.
.. _`xspec syntax`: https://codespeak.net/execnet/basics.html#xspec
.. _`socketserver.py`: https://raw.githubusercontent.com/pytest-dev/execnet/master/execnet/script/socketserver.py
.. _`execnet`: https://codespeak.net/execnet
When tests crash
----------------
If a test crashes a worker, pytest-xdist will automatically restart that worker
and report the tests failure. You can use the ``--max-worker-restart`` option
to limit the number of worker restarts that are allowed, or disable restarting
altogether using ``--max-worker-restart 0``.
How-tos
-------
Identifying the worker process during a test
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
*New in version 1.15.*
If you need to determine the identity of a worker process in
a test or fixture, you may use the ``worker_id`` fixture to do so:
.. code-block:: python
@pytest.fixture()
def user_account(worker_id):
""" use a different account in each xdist worker """
return "account_%s" % worker_id
When ``xdist`` is disabled (running with ``-n0`` for example), then
``worker_id`` will return ``"master"``.
Worker processes also have the following environment variables
defined:
* ``PYTEST_XDIST_WORKER``: the name of the worker, e.g., ``"gw2"``.
* ``PYTEST_XDIST_WORKER_COUNT``: the total number of workers in this session,
e.g., ``"4"`` when ``-n 4`` is given in the command-line.
The information about the worker_id in a test is stored in the ``TestReport`` as
well, under the ``worker_id`` attribute.
Since version 2.0, the following functions are also available in the ``xdist`` module:
.. code-block:: python
def is_xdist_worker(request_or_session) -> bool:
"""Return `True` if this is an xdist worker, `False` otherwise
:param request_or_session: the `pytest` `request` or `session` object
"""
def is_xdist_controller(request_or_session) -> bool:
"""Return `True` if this is the xdist controller, `False` otherwise
Note: this method also returns `False` when distribution has not been
activated at all.
:param request_or_session: the `pytest` `request` or `session` object
"""
def is_xdist_master(request_or_session) -> bool:
"""Deprecated alias for is_xdist_controller."""
def get_xdist_worker_id(request_or_session) -> str:
"""Return the id of the current worker ('gw0', 'gw1', etc) or 'master'
if running on the controller node.
If not distributing tests (for example passing `-n0` or not passing `-n` at all)
also return 'master'.
:param request_or_session: the `pytest` `request` or `session` object
"""
Identifying workers from the system environment
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
*New in version 2.4*
If the `setproctitle`_ package is installed, ``pytest-xdist`` will use it to
update the process title (command line) on its workers to show their current
state. The titles used are ``[pytest-xdist running] file.py/node::id`` and
``[pytest-xdist idle]``, visible in standard tools like ``ps`` and ``top`` on
Linux, Mac OS X and BSD systems. For Windows, please follow `setproctitle`_'s
pointer regarding the Process Explorer tool.
This is intended purely as an UX enhancement, e.g. to track down issues with
long-running or CPU intensive tests. Errors in changing the title are ignored
silently. Please try not to rely on the title format or title changes in
external scripts.
.. _`setproctitle`: https://pypi.org/project/setproctitle/
Uniquely identifying the current test run
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
*New in version 1.32.*
If you need to globally distinguish one test run from others in your
workers, you can use the ``testrun_uid`` fixture. For instance, let's say you
wanted to create a separate database for each test run:
.. code-block:: python
import pytest
from posix_ipc import Semaphore, O_CREAT
@pytest.fixture(scope="session", autouse=True)
def create_unique_database(testrun_uid):
""" create a unique database for this particular test run """
database_url = f"psql://myapp-{testrun_uid}"
with Semaphore(f"/{testrun_uid}-lock", flags=O_CREAT, initial_value=1):
if not database_exists(database_url):
create_database(database_url)
@pytest.fixture()
def db(testrun_uid):
""" retrieve unique database """
database_url = f"psql://myapp-{testrun_uid}"
return database_get_instance(database_url)
Additionally, during a test run, the following environment variable is defined:
* ``PYTEST_XDIST_TESTRUNUID``: the unique id of the test run.
Accessing ``sys.argv`` from the controller node in workers
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To access the ``sys.argv`` passed to the command-line of the controller node, use
``request.config.workerinput["mainargv"]``.
Specifying test exec environments in an ini file
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
You can use pytest's ini file configuration to avoid typing common options.
You can for example make running with three subprocesses your default like this:
.. code-block:: ini
[pytest]
addopts = -n3
You can also add default environments like this:
.. code-block:: ini
[pytest]
addopts = --tx ssh=myhost//python=python3.9 --tx ssh=myhost//python=python3.6
and then just type::
pytest --dist=each
to run tests in each of the environments.
Specifying "rsync" dirs in an ini-file
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In a ``tox.ini`` or ``setup.cfg`` file in your root project directory
you may specify directories to include or to exclude in synchronisation:
.. code-block:: ini
[pytest]
rsyncdirs = . mypkg helperpkg
rsyncignore = .hg
These directory specifications are relative to the directory
where the configuration file was found.
.. _`pytest-xdist`: http://pypi.python.org/pypi/pytest-xdist
.. _`pytest-xdist repository`: https://github.com/pytest-dev/pytest-xdist
.. _`pytest`: http://pytest.org
Making session-scoped fixtures execute only once
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
``pytest-xdist`` is designed so that each worker process will perform its own collection and execute
a subset of all tests. This means that tests in different processes requesting a high-level
scoped fixture (for example ``session``) will execute the fixture code more than once, which
breaks expectations and might be undesired in certain situations.
While ``pytest-xdist`` does not have a builtin support for ensuring a session-scoped fixture is
executed exactly once, this can be achieved by using a lock file for inter-process communication.
The example below needs to execute the fixture ``session_data`` only once (because it is
resource intensive, or needs to execute only once to define configuration options, etc), so it makes
use of a `FileLock <https://pypi.org/project/filelock/>`_ to produce the fixture data only once
when the first process requests the fixture, while the other processes will then read
the data from a file.
Here is the code:
.. code-block:: python
import json
import pytest
from filelock import FileLock
@pytest.fixture(scope="session")
def session_data(tmp_path_factory, worker_id):
if worker_id == "master":
# not executing in with multiple workers, just produce the data and let
# pytest's fixture caching do its job
return produce_expensive_data()
# get the temp directory shared by all workers
root_tmp_dir = tmp_path_factory.getbasetemp().parent
fn = root_tmp_dir / "data.json"
with FileLock(str(fn) + ".lock"):
if fn.is_file():
data = json.loads(fn.read_text())
else:
data = produce_expensive_data()
fn.write_text(json.dumps(data))
return data
The example above can also be use in cases a fixture needs to execute exactly once per test session, like
initializing a database service and populating initial tables.
This technique might not work for every case, but should be a starting point for many situations
where executing a high-scope fixture exactly once is important.
How does xdist work?
--------------------
``xdist`` works by spawning one or more **workers**, which are
controlled by the **controller**. Each **worker** is responsible for
performing a full test collection and afterwards running tests as
dictated by the **controller**.
The execution flow is:
1. **controller** spawns one or more **workers** at the beginning of the
test session. The communication between **controller** and **worker**
nodes makes use of `execnet <https://codespeak.net/execnet/>`__ and
its
`gateways <https://codespeak.net/execnet/basics.html#gateways-bootstrapping-python-interpreters>`__.
The actual interpreters executing the code for the **workers** might
be remote or local.
2. Each **worker** itself is a mini pytest runner. **workers** at this
point perform a full test collection, sending back the collected
test-ids back to the **controller** which does not perform any
collection itself.
3. The **controller** receives the result of the collection from all
nodes. At this point the **controller** performs some sanity check to
ensure that all **workers** collected the same tests (including
order), bailing out otherwise. If all is well, it converts the list
of test-ids into a list of simple indexes, where each index
corresponds to the position of that test in the original collection
list. This works because all nodes have the same collection list, and
saves bandwidth because the **controller** can now tell one of the
workers to just *execute test index 3* index of passing the full test
id.
4. If **dist-mode** is **each**: the **controller** just sends the full
list of test indexes to each node at this moment.
5. If **dist-mode** is **load**: the **controller** takes around 25% of
the tests and sends them one by one to each **worker** in a round
robin fashion. The rest of the tests will be distributed later as
**workers** finish tests (see below).
6. Note that ``pytest_xdist_make_scheduler`` hook can be used to
implement custom tests distribution logic.
7. **workers** re-implement ``pytest_runtestloop``: pytests default
implementation basically loops over all collected items in the
``session`` object and executes the ``pytest_runtest_protocol`` for
each test item, but in xdist **workers** sit idly waiting for
**controller** to send tests for execution. As tests are received by
**workers**, ``pytest_runtest_protocol`` is executed for each test.
Here it worth noting an implementation detail: **workers** always
must keep at least one test item on their queue due to how the
``pytest_runtest_protocol(item, nextitem)`` hook is defined: in order
to pass the ``nextitem`` to the hook, the worker must wait for more
instructions from controller before executing that remaining test. If
it receives more tests, then it can safely call
``pytest_runtest_protocol`` because it knows what the ``nextitem``
parameter will be. If it receives a “shutdown” signal, then it can
execute the hook passing ``nextitem`` as ``None``.
8. As tests are started and completed at the **workers**, the results
are sent back to the **controller**, which then just forwards the
results to the appropriate pytest hooks: ``pytest_runtest_logstart``
and ``pytest_runtest_logreport``. This way other plugins (for example
``junitxml``) can work normally. The **controller** (when in
dist-mode **load**) decides to send more tests to a node when a test
completes, using some heuristics such as test durations and how many
tests each **worker** still has to run.
9. When the **controller** has no more pending tests it will send a
“shutdown” signal to all **workers**, which will then run their
remaining tests to completion and shut down. At this point the
**controller** will sit waiting for **workers** to shut down, still
processing events such as ``pytest_runtest_logreport``.
FAQ
---
**Question**: Why does each worker do its own collection, as opposed to having the
controller collect once and distribute from that collection to the
workers?
If collection was performed by controller then it would have to
serialize collected items to send them through the wire, as workers live
in another process. The problem is that test items are not easily
(impossible?) to serialize, as they contain references to the test
functions, fixture managers, config objects, etc. Even if one manages to
serialize it, it seems it would be very hard to get it right and easy to
break by any small change in pytest.

View File

@@ -32,14 +32,8 @@ To publish a new release ``X.Y.Z``, the steps are as follows:
$ tox -e release -- X.Y.Z $ tox -e release -- X.Y.Z
#. Commit and push the branch for review. #. Commit and push the branch to ``upstream`` and open a PR.
#. Once PR is **green** and **approved**, create and push a tag:: #. Once the PR is **green** and **approved**, start the ``deploy`` workflow manually from the branch ``release-VERSION``, passing ``VERSION`` as parameter.
$ export VERSION=X.Y.Z #. Merge the release PR to ``master``.
$ git tag v$VERSION release-$VERSION
$ git push git@github.com:pytest-dev/pytest-xdist.git v$VERSION
That will build the package and publish it on ``PyPI`` automatically.
#. Merge the release PR to `master`.

View File

@@ -1,3 +0,0 @@
The ``--boxed`` commmand line argument is deprecated.
Install pytest-forked and use ``--forked`` instead.
pytest-xdist 3.0.0 will remove the ``--boxed`` argument and pytest-forked dependency.

1
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
_build/

5
docs/changelog.rst Normal file
View File

@@ -0,0 +1,5 @@
=========
Changelog
=========
.. include:: ../CHANGELOG.rst

55
docs/conf.py Normal file
View File

@@ -0,0 +1,55 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = "pytest-xdist"
copyright = "2022, holger krekel and contributors"
author = "holger krekel and contributors"
master_doc = "index"
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
"sphinx_rtd_theme",
"sphinx.ext.autodoc",
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = "sphinx_rtd_theme"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
# html_static_path = ['_static']

7
docs/crash.rst Normal file
View File

@@ -0,0 +1,7 @@
When tests crash
================
If a test crashes a worker, pytest-xdist will automatically restart that worker
and report the tests failure. You can use the ``--max-worker-restart`` option
to limit the number of worker restarts that are allowed, or disable restarting
altogether using ``--max-worker-restart 0``.

95
docs/distribution.rst Normal file
View File

@@ -0,0 +1,95 @@
.. _parallelization:
Running tests across multiple CPUs
==================================
To send tests to multiple CPUs, use the ``-n`` (or ``--numprocesses``) option::
pytest -n auto
This can lead to considerable speed ups, especially if your test suite takes a
noticeable amount of time.
With ``-n auto``, pytest-xdist will use as many processes as your computer
has CPU cores.
Use ``-n logical`` to use the number of *logical* CPU cores rather than
physical ones. This currently requires the ``psutil`` package to be installed;
if it is not, pytest-xdist will fall back to ``-n auto`` behavior.
Pass a number, e.g. ``-n 8``, to specify the number of processes explicitly.
To specify a different meaning for ``-n auto`` and ``-n logical`` for your
tests, you can:
* Set the environment variable ``PYTEST_XDIST_AUTO_NUM_WORKERS`` to the
desired number of processes.
* Implement the ``pytest_xdist_auto_num_workers``
`pytest hook <https://docs.pytest.org/en/latest/how-to/writing_plugins.html>`__
(a ``pytest_xdist_auto_num_workers(config)`` function in e.g. ``conftest.py``)
that returns the number of processes to use.
The hook can use ``config.option.numprocesses`` to determine if the user
asked for ``"auto"`` or ``"logical"``, and it can return ``None`` to fall
back to the default.
If both the hook and environment variable are specified, the hook takes
priority.
Parallelization can be configured further with these options:
* ``--maxprocesses=maxprocesses``: limit the maximum number of workers to
process the tests.
* ``--max-worker-restart``: maximum number of workers that can be restarted
when crashed (set to zero to disable this feature).
The test distribution algorithm is configured with the ``--dist`` command-line option:
.. _distribution modes:
* ``--dist load`` **(default)**: Sends pending tests to any worker that is
available, without any guaranteed order. Scheduling can be fine-tuned with
the `--maxschedchunk` option, see output of `pytest --help`.
* ``--dist loadscope``: Tests are grouped by **module** for *test functions*
and by **class** for *test methods*. Groups are distributed to available
workers as whole units. This guarantees that all tests in a group run in the
same process. This can be useful if you have expensive module-level or
class-level fixtures. Grouping by class takes priority over grouping by
module.
* ``--dist loadfile``: Tests are grouped by their containing file. Groups are
distributed to available workers as whole units. This guarantees that all
tests in a file run in the same worker.
* ``--dist loadgroup``: Tests are grouped by the ``xdist_group`` mark. Groups are
distributed to available workers as whole units. This guarantees that all
tests with same ``xdist_group`` name run in the same worker.
.. code-block:: python
@pytest.mark.xdist_group(name="group1")
def test1():
pass
class TestA:
@pytest.mark.xdist_group("group1")
def test2():
pass
This will make sure ``test1`` and ``TestA::test2`` will run in the same worker.
Tests without the ``xdist_group`` mark are distributed normally as in the ``--dist=load`` mode.
* ``--dist worksteal``: Initially, tests are distributed evenly among all
available workers. When a worker completes most of its assigned tests and
doesn't have enough tests to continue (currently, every worker needs at least
two tests in its queue), an attempt is made to reassign ("steal") a portion
of tests from some other worker's queue. The results should be similar to
the ``load`` method, but ``worksteal`` should handle tests with significantly
differing duration better, and, at the same time, it should provide similar
or better reuse of fixtures.
* ``--dist no``: The normal pytest execution mode, runs one test at a time (no distribution at all).

90
docs/how-it-works.rst Normal file
View File

@@ -0,0 +1,90 @@
How it works?
=============
``xdist`` works by spawning one or more **workers**, which are
controlled by the **controller**. Each **worker** is responsible for
performing a full test collection and afterwards running tests as
dictated by the **controller**.
The execution flow is:
1. **controller** spawns one or more **workers** at the beginning of the
test session. The communication between **controller** and **worker**
nodes makes use of `execnet <https://codespeak.net/execnet/>`__ and
its
`gateways <https://codespeak.net/execnet/basics.html#gateways-bootstrapping-python-interpreters>`__.
The actual interpreters executing the code for the **workers** might
be remote or local.
2. Each **worker** itself is a mini pytest runner. **workers** at this
point perform a full test collection, sending back the collected
test-ids back to the **controller** which does not perform any
collection itself.
3. The **controller** receives the result of the collection from all
nodes. At this point the **controller** performs some sanity check to
ensure that all **workers** collected the same tests (including
order), bailing out otherwise. If all is well, it converts the list
of test-ids into a list of simple indexes, where each index
corresponds to the position of that test in the original collection
list. This works because all nodes have the same collection list, and
saves bandwidth because the **controller** can now tell one of the
workers to just *execute test index 3* index of passing the full test
id.
4. If **dist-mode** is **each**: the **controller** just sends the full
list of test indexes to each node at this moment.
5. If **dist-mode** is **load**: the **controller** takes around 25% of
the tests and sends them one by one to each **worker** in a round
robin fashion. The rest of the tests will be distributed later as
**workers** finish tests (see below).
6. Note that ``pytest_xdist_make_scheduler`` hook can be used to
implement custom tests distribution logic.
7. **workers** re-implement ``pytest_runtestloop``: pytests default
implementation basically loops over all collected items in the
``session`` object and executes the ``pytest_runtest_protocol`` for
each test item, but in xdist **workers** sit idly waiting for
**controller** to send tests for execution. As tests are received by
**workers**, ``pytest_runtest_protocol`` is executed for each test.
Here it worth noting an implementation detail: **workers** always
must keep at least one test item on their queue due to how the
``pytest_runtest_protocol(item, nextitem)`` hook is defined: in order
to pass the ``nextitem`` to the hook, the worker must wait for more
instructions from controller before executing that remaining test. If
it receives more tests, then it can safely call
``pytest_runtest_protocol`` because it knows what the ``nextitem``
parameter will be. If it receives a “shutdown” signal, then it can
execute the hook passing ``nextitem`` as ``None``.
8. As tests are started and completed at the **workers**, the results
are sent back to the **controller**, which then just forwards the
results to the appropriate pytest hooks: ``pytest_runtest_logstart``
and ``pytest_runtest_logreport``. This way other plugins (for example
``junitxml``) can work normally. The **controller** (when in
dist-mode **load**) decides to send more tests to a node when a test
completes, using some heuristics such as test durations and how many
tests each **worker** still has to run.
9. When the **controller** has no more pending tests it will send a
“shutdown” signal to all **workers**, which will then run their
remaining tests to completion and shut down. At this point the
**controller** will sit waiting for **workers** to shut down, still
processing events such as ``pytest_runtest_logreport``.
FAQ
---
**Question**: Why does each worker do its own collection, as opposed to having the
controller collect once and distribute from that collection to the
workers?
If collection was performed by controller then it would have to
serialize collected items to send them through the wire, as workers live
in another process. The problem is that test items are not easily
(impossible?) to serialize, as they contain references to the test
functions, fixture managers, config objects, etc. Even if one manages to
serialize it, it seems it would be very hard to get it right and easy to
break by any small change in pytest.

232
docs/how-to.rst Normal file
View File

@@ -0,0 +1,232 @@
How-tos
-------
This section show cases how to accomplish some specialized tasks with ``pytest-xdist``.
Identifying the worker process during a test
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
*New in version 1.15.*
If you need to determine the identity of a worker process in
a test or fixture, you may use the ``worker_id`` fixture to do so:
.. code-block:: python
@pytest.fixture()
def user_account(worker_id):
"""use a different account in each xdist worker"""
return "account_%s" % worker_id
When ``xdist`` is disabled (running with ``-n0`` for example), then
``worker_id`` will return ``"master"``.
Worker processes also have the following environment variables
defined:
.. envvar:: PYTEST_XDIST_WORKER
The name of the worker, e.g., ``"gw2"``.
.. envvar:: PYTEST_XDIST_WORKER_COUNT
The total number of workers in this session, e.g., ``"4"`` when ``-n 4`` is given in the command-line.
The information about the worker_id in a test is stored in the ``TestReport`` as
well, under the ``worker_id`` attribute.
Since version 2.0, the following functions are also available in the ``xdist`` module:
.. autofunction:: xdist.is_xdist_worker
.. autofunction:: xdist.is_xdist_controller
.. autofunction:: xdist.is_xdist_master
.. autofunction:: xdist.get_xdist_worker_id
Identifying workers from the system environment
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
*New in version 2.4*
If the `setproctitle`_ package is installed, ``pytest-xdist`` will use it to
update the process title (command line) on its workers to show their current
state. The titles used are ``[pytest-xdist running] file.py/node::id`` and
``[pytest-xdist idle]``, visible in standard tools like ``ps`` and ``top`` on
Linux, Mac OS X and BSD systems. For Windows, please follow `setproctitle`_'s
pointer regarding the Process Explorer tool.
This is intended purely as an UX enhancement, e.g. to track down issues with
long-running or CPU intensive tests. Errors in changing the title are ignored
silently. Please try not to rely on the title format or title changes in
external scripts.
.. _`setproctitle`: https://pypi.org/project/setproctitle/
Uniquely identifying the current test run
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
*New in version 1.32.*
If you need to globally distinguish one test run from others in your
workers, you can use the ``testrun_uid`` fixture. For instance, let's say you
wanted to create a separate database for each test run:
.. code-block:: python
import pytest
from posix_ipc import Semaphore, O_CREAT
@pytest.fixture(scope="session", autouse=True)
def create_unique_database(testrun_uid):
"""create a unique database for this particular test run"""
database_url = f"psql://myapp-{testrun_uid}"
with Semaphore(f"/{testrun_uid}-lock", flags=O_CREAT, initial_value=1):
if not database_exists(database_url):
create_database(database_url)
@pytest.fixture()
def db(testrun_uid):
"""retrieve unique database"""
database_url = f"psql://myapp-{testrun_uid}"
return database_get_instance(database_url)
Additionally, during a test run, the following environment variable is defined:
.. envvar:: PYTEST_XDIST_TESTRUNUID
The unique id of the test run.
Accessing ``sys.argv`` from the controller node in workers
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To access the ``sys.argv`` passed to the command-line of the controller node, use
``request.config.workerinput["mainargv"]``.
Specifying test exec environments in an ini file
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
You can use pytest's ini file configuration to avoid typing common options.
You can for example make running with three subprocesses your default like this:
.. code-block:: ini
[pytest]
addopts = -n3
You can also add default environments like this:
.. code-block:: ini
[pytest]
addopts = --tx ssh=myhost//python=python3.9 --tx ssh=myhost//python=python3.6
and then just type::
pytest --dist=each
to run tests in each of the environments.
Specifying "rsync" dirs in an ini-file
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In a ``tox.ini`` or ``setup.cfg`` file in your root project directory
you may specify directories to include or to exclude in synchronisation:
.. code-block:: ini
[pytest]
rsyncdirs = . mypkg helperpkg
rsyncignore = .hg
These directory specifications are relative to the directory
where the configuration file was found.
.. _`pytest-xdist`: http://pypi.python.org/pypi/pytest-xdist
.. _`pytest-xdist repository`: https://github.com/pytest-dev/pytest-xdist
.. _`pytest`: http://pytest.org
Making session-scoped fixtures execute only once
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
``pytest-xdist`` is designed so that each worker process will perform its own collection and execute
a subset of all tests. This means that tests in different processes requesting a high-level
scoped fixture (for example ``session``) will execute the fixture code more than once, which
breaks expectations and might be undesired in certain situations.
While ``pytest-xdist`` does not have a builtin support for ensuring a session-scoped fixture is
executed exactly once, this can be achieved by using a lock file for inter-process communication.
The example below needs to execute the fixture ``session_data`` only once (because it is
resource intensive, or needs to execute only once to define configuration options, etc), so it makes
use of a `FileLock <https://pypi.org/project/filelock/>`_ to produce the fixture data only once
when the first process requests the fixture, while the other processes will then read
the data from a file.
Here is the code:
.. code-block:: python
import json
import pytest
from filelock import FileLock
@pytest.fixture(scope="session")
def session_data(tmp_path_factory, worker_id):
if worker_id == "master":
# not executing in with multiple workers, just produce the data and let
# pytest's fixture caching do its job
return produce_expensive_data()
# get the temp directory shared by all workers
root_tmp_dir = tmp_path_factory.getbasetemp().parent
fn = root_tmp_dir / "data.json"
with FileLock(str(fn) + ".lock"):
if fn.is_file():
data = json.loads(fn.read_text())
else:
data = produce_expensive_data()
fn.write_text(json.dumps(data))
return data
The example above can also be use in cases a fixture needs to execute exactly once per test session, like
initializing a database service and populating initial tables.
This technique might not work for every case, but should be a starting point for many situations
where executing a high-scope fixture exactly once is important.
Creating one log file for each worker
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To create one log file for each worker with ``pytest-xdist``, you can leverage :envvar:`PYTEST_XDIST_WORKER`
to generate a unique filename for each worker.
Example:
.. code-block:: python
# content of conftest.py
def pytest_configure(config):
worker_id = os.environ.get("PYTEST_XDIST_WORKER")
if worker_id is not None:
logging.basicConfig(
format=config.getini("log_file_format"),
filename=f"tests_{worker_id}.log",
level=config.getini("log_file_level"),
)
When running the tests with ``-n3``, for example, three files will be created in the current directory:
``tests_gw0.log``, ``tests_gw1.log`` and ``tests_gw2.log``.

63
docs/index.rst Normal file
View File

@@ -0,0 +1,63 @@
pytest-xdist
============
The `pytest-xdist`_ plugin extends pytest with new test execution modes, the most used being distributing
tests across multiple CPUs to speed up test execution::
pytest -n auto
With this call, pytest will spawn a number of workers processes equal to the number of available CPUs, and distribute
the tests randomly across them.
.. note::
Due to how pytest-xdist is implemented, the ``-s/--capture=no`` option does not work.
Installation
------------
Install the plugin with::
pip install pytest-xdist
To use ``psutil`` for detection of the number of CPUs available, install the ``psutil`` extra::
pip install pytest-xdist[psutil]
Features
--------
* Test run :ref:`parallelization`: tests can be executed across multiple CPUs or hosts.
This allows to speed up development or to use special resources of :ref:`remote machines`.
* ``--looponfail``: run your tests repeatedly in a subprocess. After each run
pytest waits until a file in your project changes and then re-runs
the previously failing tests. This is repeated until all tests pass
after which again a full run is performed (DEPRECATED).
* :ref:`Multi-Platform` coverage: you can specify different Python interpreters
or different platforms and run tests in parallel on all of them.
Before running tests remotely, ``pytest`` efficiently "rsyncs" your
program source code to the remote place.
You may specify different Python versions and interpreters. It does not
installs/synchronize dependencies however.
**Note**: this mode exists mostly for backward compatibility, as modern development
relies on continuous integration for multi-platform testing.
.. toctree::
:maxdepth: 2
:caption: Contents:
distribution
subprocess
remote
crash
how-to
how-it-works
known-limitations
changelog

View File

@@ -0,0 +1,71 @@
Known limitations
=================
pytest-xdist has some limitations that may be supported in pytest but can't be supported in pytest-xdist.
Order and amount of test must be consistent
-------------------------------------------
It is not possible to have tests that differ in order or their amount across workers.
This is especially true with ``pytest.mark.parametrize``, when values are produced with sets or other unordered iterables/generators.
Example:
.. code-block:: python
import pytest
@pytest.mark.parametrize("param", {"a", "b"})
def test_pytest_parametrize_unordered(param):
pass
In the example above, the fact that ``set`` are not necessarily ordered can cause different workers
to collect tests in different order, which will throw an error.
Workarounds
~~~~~~~~~~~
A solution to this is to guarantee that the parametrized values have the same order.
Some solutions:
* Convert your sequence to a ``list``.
.. code-block:: python
import pytest
@pytest.mark.parametrize("param", ["a", "b"])
def test_pytest_parametrize_unordered(param):
pass
* Sort your sequence, guaranteeing order.
.. code-block:: python
import pytest
@pytest.mark.parametrize("param", sorted({"a", "b"}))
def test_pytest_parametrize_unordered(param):
pass
Output (stdout and stderr) from workers
---------------------------------------
The ``-s``/``--capture=no`` option is meant to disable pytest capture, so users can then see stdout and stderr output in the terminal from tests and application code in real time.
However, this option does not work with ``pytest-xdist`` because `execnet <https://github.com/pytest-dev/execnet>`__ the underlying library used for communication between master and workers, does not support transferring stdout/stderr from workers.
Currently, there are no plans to support this in ``pytest-xdist``.
Debugging
~~~~~~~~~
This also means that debugging using PDB (or any other debugger that wants to use standard I/O) will not work. The ``--pdb`` option is disabled when distributing tests with ``pytest-xdist`` for this reason.
It is generally likely best to use ``pytest-xdist`` to find failing tests and then debug them without distribution; however, if you need to debug from within a worker process (for example, to address failures that only happen when running tests concurrently), remote debuggers (for example, `python-remote-pdb <https://github.com/ionelmc/python-remote-pdb>`__ or `python-web-pdb <https://github.com/romanvm/python-web-pdb>`__) have been reported to work for this purpose.

83
docs/remote.rst Normal file
View File

@@ -0,0 +1,83 @@
.. _`Multi-Platform`:
.. _`remote machines`:
Sending tests to remote SSH accounts
====================================
.. deprecated:: 3.0
.. warning::
This feature is deprecated because the support for ``rsync`` is faulty
in terms of reproducing the development environment in the remote
worker, and there is no clear solution moving forward.
This feature is scheduled to be removed in release 4.0, to let the team
focus on a smaller set of features.
Suppose you have a package ``mypkg`` which contains some
tests that you can successfully run locally. And you
have a ssh-reachable machine ``myhost``. Then
you can ad-hoc distribute your tests by typing::
pytest -d --rsyncdir mypkg --tx ssh=myhostpopen mypkg/tests/unit/test_something.py
This will synchronize your :code:`mypkg` package directory
to a remote ssh account and then locally collect tests
and send them to remote places for execution.
You can specify multiple :code:`--rsyncdir` directories
to be sent to the remote side.
.. note::
For pytest to collect and send tests correctly
you not only need to make sure all code and tests
directories are rsynced, but that any test (sub) directory
also has an :code:`__init__.py` file because internally
pytest references tests as a fully qualified python
module path. **You will otherwise get strange errors**
during setup of the remote side.
You can specify multiple :code:`--rsyncignore` glob patterns
to be ignored when file are sent to the remote side.
There are also internal ignores: :code:`.*, *.pyc, *.pyo, *~`
Those you cannot override using rsyncignore command-line or
ini-file option(s).
Sending tests to remote Socket Servers
--------------------------------------
Download the single-module `socketserver.py`_ Python program
and run it like this::
python socketserver.py
It will tell you that it starts listening on the default
port. You can now on your home machine specify this
new socket host with something like this::
pytest -d --tx socket=192.168.1.102:8888 --rsyncdir mypkg
Running tests on many platforms at once
---------------------------------------
The basic command to run tests on multiple platforms is::
pytest --dist=each --tx=spec1 --tx=spec2
If you specify a windows host, an OSX host and a Linux
environment this command will send each tests to all
platforms - and report back failures from all platforms
at once. The specifications strings use the `xspec syntax`_.
.. _`xspec syntax`: https://codespeak.net/execnet/basics.html#xspec
.. _`execnet`: https://codespeak.net/execnet
.. _`socketserver.py`: https://raw.githubusercontent.com/pytest-dev/execnet/master/execnet/script/socketserver.py

16
docs/subprocess.rst Normal file
View File

@@ -0,0 +1,16 @@
Running tests in a Python subprocess
====================================
To instantiate a ``python3.9`` subprocess and send tests to it, you may type::
pytest -d --tx popen//python=python3.9
This will start a subprocess which is run with the ``python3.9``
Python interpreter, found in your system binary lookup path.
If you prefix the --tx option value like this::
--tx 3*popen//python=python3.9
then three subprocesses would be created and tests
will be load-balanced across these three processes.

View File

@@ -1,6 +1,5 @@
[build-system] [build-system]
requires = [ requires = [
# sync with setup.py until we discard non-pep-517/518
"setuptools>=45.0", "setuptools>=45.0",
"setuptools-scm[toml]>=6.2.3", "setuptools-scm[toml]>=6.2.3",
"wheel", "wheel",
@@ -17,32 +16,23 @@ directory = "changelog/"
title_format = "pytest-xdist {version} ({project_date})" title_format = "pytest-xdist {version} ({project_date})"
template = "changelog/_template.rst" template = "changelog/_template.rst"
[[tool.towncrier.type]] [tool.towncrier.fragment.removal]
directory = "removal" name = "Removals"
name = "Deprecations and Removals"
showcontent = true
[[tool.towncrier.type]] [tool.towncrier.fragment.deprecation]
directory = "feature" name = "Deprecations"
name = "Features"
showcontent = true
[[tool.towncrier.type]] [tool.towncrier.fragment.feature]
directory = "bugfix" name = "Features"
name = "Bug Fixes"
showcontent = true
[[tool.towncrier.type]] [tool.towncrier.fragment.bugfix]
directory = "vendor" name = "Bug Fixes"
name = "Vendored Libraries"
showcontent = true
[[tool.towncrier.type]] [tool.towncrier.fragment.vendor]
directory = "doc" name = "Vendored Libraries"
name = "Improved Documentation"
showcontent = true
[[tool.towncrier.type]] [tool.towncrier.fragment.doc]
directory = "trivial" name = "Improved Documentation"
name = "Trivial Changes"
showcontent = true [tool.towncrier.fragment.trivial]
name = "Trivial Changes"

View File

@@ -1,7 +1,8 @@
[metadata] [metadata]
name = pytest-xdist name = pytest-xdist
description = pytest xdist plugin for distributed testing and loop-on-failing modes description = pytest xdist plugin for distributed testing, most importantly across multiple CPUs
long_description = file: README.rst long_description = file: README.rst
long_description_content_type = text/x-rst
license = MIT license = MIT
author = holger krekel and contributors author = holger krekel and contributors
author_email = pytest-dev@python.org,holger@merlinux.eu author_email = pytest-dev@python.org,holger@merlinux.eu
@@ -24,23 +25,26 @@ classifiers =
Programming Language :: Python Programming Language :: Python
Programming Language :: Python :: 3 Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
license_file = LICENSE license_file = LICENSE
project_urls =
Documentation=https://pytest-xdist.readthedocs.io/en/latest
Changelog=https://pytest-xdist.readthedocs.io/en/latest/changelog.html
Source=https://github.com/pytest-dev/pytest-xdist
Tracker=https://github.com/pytest-dev/pytest-xdist/issues
[options] [options]
packages = find: packages = find:
package_dir = =src package_dir = =src
zip_safe = False zip_safe = False
python_requires = >=3.6 python_requires = >=3.7
install_requires = install_requires =
execnet>=1.1 execnet>=1.1
pytest>=6.2.0 pytest>=6.2.0
pytest-forked
setup_requires = setuptools_scm>=6.0
[options.packages.find] [options.packages.find]
where = src where = src
@@ -57,6 +61,8 @@ psutil = psutil>=3.0
setproctitle = setproctitle setproctitle = setproctitle
[flake8] [flake8]
# Ignore any errors related to formatting, let black worry/fix them.
ignore = E501, W503, E203
max-line-length = 100 max-line-length = 100
[mypy] [mypy]

View File

@@ -1,4 +0,0 @@
from setuptools import setup
if __name__ == "__main__":
setup()

19
src/xdist/_path.py Normal file
View File

@@ -0,0 +1,19 @@
import os
from itertools import chain
from pathlib import Path
from typing import Callable, Iterator
def visit_path(
path: Path, *, filter: Callable[[Path], bool], recurse: Callable[[Path], bool]
) -> Iterator[Path]:
"""
Implements the interface of ``py.path.local.visit()`` for Path objects,
to simplify porting the code over from ``py.path.local``.
"""
for dirpath, dirnames, filenames in os.walk(path):
dirnames[:] = [x for x in dirnames if recurse(Path(dirpath, x))]
for name in chain(dirnames, filenames):
p = Path(dirpath, name)
if filter(p):
yield p

View File

@@ -1,6 +1,11 @@
import py from __future__ import annotations
import sys
from enum import Enum, auto
from typing import Sequence
import pytest import pytest
from xdist.remote import Producer
from xdist.workermanage import NodeManager from xdist.workermanage import NodeManager
from xdist.scheduler import ( from xdist.scheduler import (
EachScheduling, EachScheduling,
@@ -8,6 +13,7 @@ from xdist.scheduler import (
LoadScopeScheduling, LoadScopeScheduling,
LoadFileScheduling, LoadFileScheduling,
LoadGroupScheduling, LoadGroupScheduling,
WorkStealingScheduling,
) )
@@ -34,9 +40,7 @@ class DSession:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.log = py.log.Producer("dsession") self.log = Producer("dsession", enabled=config.option.debug)
if not config.option.debug:
py.log.setconsumer(self.log._keywords, None)
self.nodemanager = None self.nodemanager = None
self.sched = None self.sched = None
self.shuttingdown = False self.shuttingdown = False
@@ -102,6 +106,7 @@ class DSession:
"loadscope": LoadScopeScheduling, "loadscope": LoadScopeScheduling,
"loadfile": LoadFileScheduling, "loadfile": LoadFileScheduling,
"loadgroup": LoadGroupScheduling, "loadgroup": LoadGroupScheduling,
"worksteal": WorkStealingScheduling,
} }
return schedulers[dist](config, log) return schedulers[dist](config, log)
@@ -171,7 +176,7 @@ class DSession:
""" """
self.config.hook.pytest_testnodedown(node=node, error=None) self.config.hook.pytest_testnodedown(node=node, error=None)
if node.workeroutput["exitstatus"] == 2: # keyboard-interrupt if node.workeroutput["exitstatus"] == 2: # keyboard-interrupt
self.shouldstop = "{} received keyboard-interrupt".format(node) self.shouldstop = f"{node} received keyboard-interrupt"
self.worker_errordown(node, "keyboard-interrupt") self.worker_errordown(node, "keyboard-interrupt")
return return
if node in self.sched.nodes: if node in self.sched.nodes:
@@ -225,13 +230,14 @@ class DSession:
self.triggershutdown() self.triggershutdown()
else: else:
self.report_line("\nreplacing crashed worker %s" % node.gateway.id) self.report_line("\nreplacing crashed worker %s" % node.gateway.id)
self.shuttingdown = False
self._clone_node(node) self._clone_node(node)
self._active_nodes.remove(node) self._active_nodes.remove(node)
@pytest.hookimpl @pytest.hookimpl
def pytest_terminal_summary(self, terminalreporter): def pytest_terminal_summary(self, terminalreporter):
if self.config.option.verbose >= 0 and self._summary_report: if self.config.option.verbose >= 0 and self._summary_report:
terminalreporter.write_sep("=", "xdist: {}".format(self._summary_report)) terminalreporter.write_sep("=", f"xdist: {self._summary_report}")
def worker_collectionfinish(self, node, ids): def worker_collectionfinish(self, node, ids):
"""worker has finished test collection. """worker has finished test collection.
@@ -250,14 +256,16 @@ class DSession:
self._session.testscollected = len(ids) self._session.testscollected = len(ids)
self.sched.add_node_collection(node, ids) self.sched.add_node_collection(node, ids)
if self.terminal: if self.terminal:
self.trdist.setstatus(node.gateway.spec, "[%d]" % (len(ids))) self.trdist.setstatus(
node.gateway.spec, WorkerStatus.CollectionDone, tests_collected=len(ids)
)
if self.sched.collection_is_completed: if self.sched.collection_is_completed:
if self.terminal and not self.sched.has_pending: if self.terminal and not self.sched.has_pending:
self.trdist.ensure_show_status() self.trdist.ensure_show_status()
self.terminal.write_line("") self.terminal.write_line("")
if self.config.option.verbose > 0: if self.config.option.verbose > 0:
self.terminal.write_line( self.terminal.write_line(
"scheduling tests via %s" % (self.sched.__class__.__name__) f"scheduling tests via {self.sched.__class__.__name__}"
) )
self.sched.schedule() self.sched.schedule()
@@ -283,6 +291,17 @@ class DSession:
""" """
self.sched.mark_test_complete(node, item_index, duration) self.sched.mark_test_complete(node, item_index, duration)
def worker_unscheduled(self, node, indices):
"""
Emitted when a node fires the 'unscheduled' event, signalling that
some tests have been removed from the worker's queue and should be
sent to some worker again.
This should happen only in response to 'steal' command, so schedulers
not using 'steal' command don't have to implement it.
"""
self.sched.remove_pending_tests_from_node(node, indices)
def worker_collectreport(self, node, rep): def worker_collectreport(self, node, rep):
"""Emitted when a node calls the pytest_collectreport hook. """Emitted when a node calls the pytest_collectreport hook.
@@ -333,7 +352,7 @@ class DSession:
if rep.failed: if rep.failed:
self.countfailures += 1 self.countfailures += 1
if self.maxfail and self.countfailures >= self.maxfail: if self.maxfail and self.countfailures >= self.maxfail:
self.shouldstop = "stopping after %d failures" % (self.countfailures) self.shouldstop = f"stopping after {self.countfailures} failures"
def triggershutdown(self): def triggershutdown(self):
self.log("triggering shutdown") self.log("triggering shutdown")
@@ -346,7 +365,7 @@ class DSession:
# XXX count no of failures and retry N times # XXX count no of failures and retry N times
runner = self.config.pluginmanager.getplugin("runner") runner = self.config.pluginmanager.getplugin("runner")
fspath = nodeid.split("::")[0] fspath = nodeid.split("::")[0]
msg = "worker {!r} crashed while running {!r}".format(worker.gateway.id, nodeid) msg = f"worker {worker.gateway.id!r} crashed while running {nodeid!r}"
rep = runner.TestReport( rep = runner.TestReport(
nodeid, (fspath, None, fspath), (), "failed", msg, "???" nodeid, (fspath, None, fspath), (), "failed", msg, "???"
) )
@@ -360,34 +379,51 @@ class DSession:
self.config.hook.pytest_runtest_logreport(report=rep) self.config.hook.pytest_runtest_logreport(report=rep)
class WorkerStatus(Enum):
"""Status of each worker during creation/collection."""
# Worker spec has just been created.
Created = auto()
# Worker has been initialized.
Initialized = auto()
# Worker is now ready for collection.
ReadyForCollection = auto()
# Worker has finished collection.
CollectionDone = auto()
class TerminalDistReporter: class TerminalDistReporter:
def __init__(self, config): def __init__(self, config) -> None:
self.config = config self.config = config
self.tr = config.pluginmanager.getplugin("terminalreporter") self.tr = config.pluginmanager.getplugin("terminalreporter")
self._status = {} self._status: dict[str, tuple[WorkerStatus, int]] = {}
self._lastlen = 0 self._lastlen = 0
self._isatty = getattr(self.tr, "isatty", self.tr.hasmarkup) self._isatty = getattr(self.tr, "isatty", self.tr.hasmarkup)
def write_line(self, msg): def write_line(self, msg: str) -> None:
self.tr.write_line(msg) self.tr.write_line(msg)
def ensure_show_status(self): def ensure_show_status(self) -> None:
if not self._isatty: if not self._isatty:
self.write_line(self.getstatus()) self.write_line(self.getstatus())
def setstatus(self, spec, status, show=True): def setstatus(
self._status[spec.id] = status self, spec, status: WorkerStatus, *, tests_collected: int, show: bool = True
) -> None:
self._status[spec.id] = (status, tests_collected)
if show and self._isatty: if show and self._isatty:
self.rewrite(self.getstatus()) self.rewrite(self.getstatus())
def getstatus(self): def getstatus(self) -> str:
if self.config.option.verbose >= 0: if self.config.option.verbose >= 0:
parts = [ line = get_workers_status_line(list(self._status.values()))
"{} {}".format(spec.id, self._status[spec.id]) for spec in self._specs if line:
] return line
return " / ".join(parts)
else: return "bringing up nodes..."
return "bringing up nodes..."
def rewrite(self, line, newline=False): def rewrite(self, line, newline=False):
pline = line + " " * max(self._lastlen - len(line), 0) pline = line + " " * max(self._lastlen - len(line), 0)
@@ -399,40 +435,44 @@ class TerminalDistReporter:
self.tr.rewrite(pline, bold=True) self.tr.rewrite(pline, bold=True)
@pytest.hookimpl @pytest.hookimpl
def pytest_xdist_setupnodes(self, specs): def pytest_xdist_setupnodes(self, specs) -> None:
self._specs = specs self._specs = specs
for spec in specs: for spec in specs:
self.setstatus(spec, "I", show=False) self.setstatus(spec, WorkerStatus.Created, tests_collected=0, show=False)
self.setstatus(spec, "I", show=True) self.setstatus(spec, WorkerStatus.Created, tests_collected=0, show=True)
self.ensure_show_status() self.ensure_show_status()
@pytest.hookimpl @pytest.hookimpl
def pytest_xdist_newgateway(self, gateway): def pytest_xdist_newgateway(self, gateway) -> None:
if self.config.option.verbose > 0: rinfo = gateway._rinfo()
rinfo = gateway._rinfo() is_local = rinfo.executable == sys.executable
if self.config.option.verbose > 0 and not is_local:
version = "%s.%s.%s" % rinfo.version_info[:3] version = "%s.%s.%s" % rinfo.version_info[:3]
self.rewrite( self.rewrite(
"[%s] %s Python %s cwd: %s" "[%s] %s Python %s cwd: %s"
% (gateway.id, rinfo.platform, version, rinfo.cwd), % (gateway.id, rinfo.platform, version, rinfo.cwd),
newline=True, newline=True,
) )
self.setstatus(gateway.spec, "C") self.setstatus(gateway.spec, WorkerStatus.Initialized, tests_collected=0)
@pytest.hookimpl @pytest.hookimpl
def pytest_testnodeready(self, node): def pytest_testnodeready(self, node) -> None:
if self.config.option.verbose > 0: d = node.workerinfo
d = node.workerinfo is_local = d.get("executable") == sys.executable
if self.config.option.verbose > 0 and not is_local:
infoline = "[{}] Python {}".format( infoline = "[{}] Python {}".format(
d["id"], d["version"].replace("\n", " -- ") d["id"], d["version"].replace("\n", " -- ")
) )
self.rewrite(infoline, newline=True) self.rewrite(infoline, newline=True)
self.setstatus(node.gateway.spec, "ok") self.setstatus(
node.gateway.spec, WorkerStatus.ReadyForCollection, tests_collected=0
)
@pytest.hookimpl @pytest.hookimpl
def pytest_testnodedown(self, node, error): def pytest_testnodedown(self, node, error) -> None:
if not error: if not error:
return return
self.write_line("[{}] node down: {}".format(node.gateway.id, error)) self.write_line(f"[{node.gateway.id}] node down: {error}")
def get_default_max_worker_restart(config): def get_default_max_worker_restart(config):
@@ -447,3 +487,36 @@ def get_default_max_worker_restart(config):
# if --max-worker-restart was not provided, use a reasonable default (#226) # if --max-worker-restart was not provided, use a reasonable default (#226)
result = config.option.numprocesses * 4 result = config.option.numprocesses * 4
return result return result
def get_workers_status_line(
status_and_items: Sequence[tuple[WorkerStatus, int]]
) -> str:
"""
Return the line to display during worker setup/collection based on the
status of the workers and number of tests collected for each.
"""
statuses = [s for s, c in status_and_items]
total_workers = len(statuses)
workers_noun = "worker" if total_workers == 1 else "workers"
if status_and_items and all(s == WorkerStatus.CollectionDone for s in statuses):
# All workers collect the same number of items, so we grab
# the total number of items from the first worker.
first = status_and_items[0]
status, tests_collected = first
tests_noun = "item" if tests_collected == 1 else "items"
return f"{total_workers} {workers_noun} [{tests_collected} {tests_noun}]"
if WorkerStatus.CollectionDone in statuses:
done = sum(1 for s, c in status_and_items if c > 0)
return f"collecting: {done}/{total_workers} {workers_noun}"
if WorkerStatus.ReadyForCollection in statuses:
ready = statuses.count(WorkerStatus.ReadyForCollection)
return f"ready: {ready}/{total_workers} {workers_noun}"
if WorkerStatus.Initialized in statuses:
initialized = statuses.count(WorkerStatus.Initialized)
return f"initialized: {initialized}/{total_workers} {workers_noun}"
if WorkerStatus.Created in statuses:
created = statuses.count(WorkerStatus.Created)
return f"created: {created}/{total_workers} {workers_noun}"
return ""

View File

@@ -6,11 +6,17 @@
processes) otherwise changes to source code can crash processes) otherwise changes to source code can crash
the controlling process which should best never happen. the controlling process which should best never happen.
""" """
import py import os
from pathlib import Path
from typing import Dict, Sequence
import pytest import pytest
import sys import sys
import time import time
import execnet import execnet
from _pytest._io import TerminalWriter
from xdist._path import visit_path
@pytest.hookimpl @pytest.hookimpl
@@ -29,7 +35,6 @@ def pytest_addoption(parser):
@pytest.hookimpl @pytest.hookimpl
def pytest_cmdline_main(config): def pytest_cmdline_main(config):
if config.getoption("looponfail"): if config.getoption("looponfail"):
usepdb = config.getoption("usepdb", False) # a core option usepdb = config.getoption("usepdb", False) # a core option
if usepdb: if usepdb:
@@ -38,9 +43,12 @@ def pytest_cmdline_main(config):
return 2 # looponfail only can get stop with ctrl-C anyway return 2 # looponfail only can get stop with ctrl-C anyway
def looponfail_main(config): def looponfail_main(config: "pytest.Config") -> None:
remotecontrol = RemoteControl(config) remotecontrol = RemoteControl(config)
rootdirs = [py.path.local(root) for root in config.getini("looponfailroots")] config_roots = config.getini("looponfailroots")
if not config_roots:
config_roots = [Path.cwd()]
rootdirs = [Path(root) for root in config_roots]
statrecorder = StatRecorder(rootdirs) statrecorder = StatRecorder(rootdirs)
try: try:
while 1: while 1:
@@ -71,7 +79,7 @@ class RemoteControl:
def setup(self, out=None): def setup(self, out=None):
if out is None: if out is None:
out = py.io.TerminalWriter() out = TerminalWriter()
if hasattr(self, "gateway"): if hasattr(self, "gateway"):
raise ValueError("already have gateway %r" % self.gateway) raise ValueError("already have gateway %r" % self.gateway)
self.trace("setting up worker session") self.trace("setting up worker session")
@@ -129,7 +137,7 @@ class RemoteControl:
def repr_pytest_looponfailinfo(failreports, rootdirs): def repr_pytest_looponfailinfo(failreports, rootdirs):
tr = py.io.TerminalWriter() tr = TerminalWriter()
if failreports: if failreports:
tr.sep("#", "LOOPONFAILING", bold=True) tr.sep("#", "LOOPONFAILING", bold=True)
for report in failreports: for report in failreports:
@@ -137,7 +145,7 @@ def repr_pytest_looponfailinfo(failreports, rootdirs):
tr.line(report, red=True) tr.line(report, red=True)
tr.sep("#", "waiting for changes", bold=True) tr.sep("#", "waiting for changes", bold=True)
for rootdir in rootdirs: for rootdir in rootdirs:
tr.line("### Watching: {}".format(rootdir), bold=True) tr.line(f"### Watching: {rootdir}", bold=True)
def init_worker_session(channel, args, option_dict): def init_worker_session(channel, args, option_dict):
@@ -225,16 +233,16 @@ class WorkerFailSession:
class StatRecorder: class StatRecorder:
def __init__(self, rootdirlist): def __init__(self, rootdirlist: Sequence[Path]) -> None:
self.rootdirlist = rootdirlist self.rootdirlist = rootdirlist
self.statcache = {} self.statcache: Dict[Path, os.stat_result] = {}
self.check() # snapshot state self.check() # snapshot state
def fil(self, p): def fil(self, p: Path) -> bool:
return p.check(file=1, dotfile=0) and p.ext != ".pyc" return p.is_file() and not p.name.startswith(".") and p.suffix != ".pyc"
def rec(self, p): def rec(self, p: Path) -> bool:
return p.check(dotfile=0) return not p.name.startswith(".") and p.exists()
def waitonchange(self, checkinterval=1.0): def waitonchange(self, checkinterval=1.0):
while 1: while 1:
@@ -243,34 +251,34 @@ class StatRecorder:
return return
time.sleep(checkinterval) time.sleep(checkinterval)
def check(self, removepycfiles=True): # noqa, too complex def check(self, removepycfiles: bool = True) -> bool: # noqa, too complex
changed = False changed = False
statcache = self.statcache newstat: Dict[Path, os.stat_result] = {}
newstat = {}
for rootdir in self.rootdirlist: for rootdir in self.rootdirlist:
for path in rootdir.visit(self.fil, self.rec): for path in visit_path(rootdir, filter=self.fil, recurse=self.rec):
oldstat = statcache.pop(path, None) oldstat = self.statcache.pop(path, None)
try: try:
newstat[path] = curstat = path.stat() curstat = path.stat()
except py.error.ENOENT: except OSError:
if oldstat: if oldstat:
changed = True changed = True
else: else:
if oldstat: newstat[path] = curstat
if oldstat is not None:
if ( if (
oldstat.mtime != curstat.mtime oldstat.st_mtime != curstat.st_mtime
or oldstat.size != curstat.size or oldstat.st_size != curstat.st_size
): ):
changed = True changed = True
print("# MODIFIED", path) print("# MODIFIED", path)
if removepycfiles and path.ext == ".py": if removepycfiles and path.suffix == ".py":
pycfile = path + "c" pycfile = path.with_suffix(".pyc")
if pycfile.check(): if pycfile.is_file():
pycfile.remove() os.unlink(pycfile)
else: else:
changed = True changed = True
if statcache: if self.statcache:
changed = True changed = True
self.statcache = newstat self.statcache = newstat
return changed return changed

View File

@@ -24,12 +24,16 @@ def pytest_xdist_newgateway(gateway):
"""called on new raw gateway creation.""" """called on new raw gateway creation."""
@pytest.hookspec() @pytest.hookspec(
warn_on_impl="rsync feature is deprecated and will be removed in pytest-xdist 4.0"
)
def pytest_xdist_rsyncstart(source, gateways): def pytest_xdist_rsyncstart(source, gateways):
"""called before rsyncing a directory to remote gateways takes place.""" """called before rsyncing a directory to remote gateways takes place."""
@pytest.hookspec() @pytest.hookspec(
warn_on_impl="rsync feature is deprecated and will be removed in pytest-xdist 4.0"
)
def pytest_xdist_rsyncfinish(source, gateways): def pytest_xdist_rsyncfinish(source, gateways):
"""called after rsyncing a directory to remote gateways takes place.""" """called after rsyncing a directory to remote gateways takes place."""

View File

@@ -1,9 +1,8 @@
import os import os
import uuid import uuid
import sys import sys
from pathlib import Path import warnings
import py
import pytest import pytest
@@ -14,6 +13,15 @@ _sys_path = list(sys.path) # freeze a copy of sys.path at interpreter startup
@pytest.hookimpl @pytest.hookimpl
def pytest_xdist_auto_num_workers(config): def pytest_xdist_auto_num_workers(config):
env_var = os.environ.get("PYTEST_XDIST_AUTO_NUM_WORKERS")
if env_var:
try:
return int(env_var)
except ValueError:
warnings.warn(
"PYTEST_XDIST_AUTO_NUM_WORKERS is not a number: {env_var!r}. Ignoring it."
)
try: try:
import psutil import psutil
except ImportError: except ImportError:
@@ -86,7 +94,15 @@ def pytest_addoption(parser):
"--dist", "--dist",
metavar="distmode", metavar="distmode",
action="store", action="store",
choices=["each", "load", "loadscope", "loadfile", "loadgroup", "no"], choices=[
"each",
"load",
"loadscope",
"loadfile",
"loadgroup",
"worksteal",
"no",
],
dest="dist", dest="dist",
default="no", default="no",
help=( help=(
@@ -99,6 +115,8 @@ def pytest_addoption(parser):
"loadfile: load balance by sending test grouped by file" "loadfile: load balance by sending test grouped by file"
" to any available environment.\n\n" " to any available environment.\n\n"
"loadgroup: like load, but sends tests marked with 'xdist_group' to the same worker.\n\n" "loadgroup: like load, but sends tests marked with 'xdist_group' to the same worker.\n\n"
"worksteal: split the test suite between available environments,"
" then rebalance when any worker runs out of tests.\n\n"
"(default) no: run tests inprocess, don't distribute." "(default) no: run tests inprocess, don't distribute."
), ),
) )
@@ -135,11 +153,6 @@ def pytest_addoption(parser):
metavar="GLOB", metavar="GLOB",
help="add expression for ignores when rsyncing to remote tx nodes.", help="add expression for ignores when rsyncing to remote tx nodes.",
) )
group.addoption(
"--boxed",
action="store_true",
help="backward compatibility alias for pytest-forked --forked",
)
group.addoption( group.addoption(
"--testrunuid", "--testrunuid",
action="store", action="store",
@@ -150,6 +163,21 @@ def pytest_addoption(parser):
"on every test run." "on every test run."
), ),
) )
group.addoption(
"--maxschedchunk",
action="store",
type=int,
help=(
"Maximum number of tests scheduled in one step for --dist=load. "
"Setting it to 1 will force pytest to send tests to workers one by "
"one - might be useful for a small number of slow tests. "
"Larger numbers will allow the scheduler to submit consecutive "
"chunks of tests to workers - allows reusing fixtures. "
"Due to implementation reasons, at least 2 tests are scheduled per "
"worker at the start. Only later tests can be scheduled one by one. "
"Unlimited if not set."
),
)
parser.addini( parser.addini(
"rsyncdirs", "rsyncdirs",
@@ -164,8 +192,7 @@ def pytest_addoption(parser):
parser.addini( parser.addini(
"looponfailroots", "looponfailroots",
type="paths" if PYTEST_GTE_7 else "pathlist", type="paths" if PYTEST_GTE_7 else "pathlist",
help="directories to check for changes", help="directories to check for changes. Default: current directory.",
default=[Path.cwd() if PYTEST_GTE_7 else py.path.local()],
) )
@@ -188,7 +215,19 @@ def pytest_addhooks(pluginmanager):
@pytest.hookimpl(trylast=True) @pytest.hookimpl(trylast=True)
def pytest_configure(config): def pytest_configure(config):
if config.getoption("dist") != "no" and not config.getvalue("collectonly"): config_line = (
"xdist_group: specify group for tests should run in same session."
"in relation to one another. Provided by pytest-xdist."
)
config.addinivalue_line("markers", config_line)
# Skip this plugin entirely when only doing collection.
if config.getvalue("collectonly"):
return
# Create the distributed session in case we have a valid distribution
# mode and test environments.
if config.getoption("dist") != "no" and config.getoption("tx"):
from xdist.dsession import DSession from xdist.dsession import DSession
session = DSession(config) session = DSession(config)
@@ -196,20 +235,21 @@ def pytest_configure(config):
tr = config.pluginmanager.getplugin("terminalreporter") tr = config.pluginmanager.getplugin("terminalreporter")
if tr: if tr:
tr.showfspath = False tr.showfspath = False
if config.getoption("boxed"):
# Deprecation warnings for deprecated command-line/configuration options.
if config.getoption("looponfail", None) or config.getini("looponfailroots"):
warning = DeprecationWarning( warning = DeprecationWarning(
"The --boxed commmand line argument is deprecated. " "The --looponfail command line argument and looponfailroots config variable are deprecated.\n"
"Install pytest-forked and use --forked instead. " "The loop-on-fail feature will be removed in pytest-xdist 4.0."
"pytest-xdist 3.0.0 will remove the --boxed argument and pytest-forked dependency."
) )
config.issue_config_time_warning(warning, 2) config.issue_config_time_warning(warning, 2)
config.option.forked = True
config_line = ( if config.getoption("rsyncdir", None) or config.getini("rsyncdirs"):
"xdist_group: specify group for tests should run in same session." warning = DeprecationWarning(
"in relation to one another. " + "Provided by pytest-xdist." "The --rsyncdir command line argument and rsyncdirs config variable are deprecated.\n"
) "The rsync feature will be removed in pytest-xdist 4.0."
config.addinivalue_line("markers", config_line) )
config.issue_config_time_warning(warning, 2)
@pytest.hookimpl(tryfirst=True) @pytest.hookimpl(tryfirst=True)

View File

@@ -6,11 +6,12 @@
needs not to be installed in remote environments. needs not to be installed in remote environments.
""" """
import contextlib
import sys import sys
import os import os
import time import time
from typing import Any
import py
import pytest import pytest
from execnet.gateway_base import dumps, DumpError from execnet.gateway_base import dumps, DumpError
@@ -24,6 +25,29 @@ except ImportError:
pass pass
class Producer:
"""
Simplified implementation of the same interface as py.log, for backward compatibility
since we dropped the dependency on pylib.
Note: this is defined here because this module can't depend on xdist, so we need
to have the other way around.
"""
def __init__(self, name: str, *, enabled: bool = True):
self.name = name
self.enabled = enabled
def __repr__(self) -> str:
return f"{type(self).__name__}({self.name!r}, enabled={self.enabled})"
def __call__(self, *a: Any, **k: Any) -> None:
if self.enabled:
print(f"[{self.name}]", *a, **k, file=sys.stderr)
def __getattr__(self, name: str) -> "Producer":
return type(self)(name, enabled=self.enabled)
def worker_title(title): def worker_title(title):
try: try:
setproctitle(title) setproctitle(title)
@@ -33,16 +57,31 @@ def worker_title(title):
class WorkerInteractor: class WorkerInteractor:
SHUTDOWN_MARK = object()
QUEUE_REPLACED_MARK = object()
def __init__(self, config, channel): def __init__(self, config, channel):
self.config = config self.config = config
self.workerid = config.workerinput.get("workerid", "?") self.workerid = config.workerinput.get("workerid", "?")
self.testrunuid = config.workerinput["testrunuid"] self.testrunuid = config.workerinput["testrunuid"]
self.log = py.log.Producer("worker-%s" % self.workerid) self.log = Producer(f"worker-{self.workerid}", enabled=config.option.debug)
if not config.option.debug:
py.log.setconsumer(self.log._keywords, None)
self.channel = channel self.channel = channel
self.torun = self._make_queue()
self.nextitem_index = None
config.pluginmanager.register(self) config.pluginmanager.register(self)
def _make_queue(self):
return self.channel.gateway.execmodel.queue.Queue()
def _get_next_item_index(self):
"""Gets the next item from test queue. Handles the case when the queue
is replaced concurrently in another thread.
"""
result = self.torun.get()
while result is self.QUEUE_REPLACED_MARK:
result = self.torun.get()
return result
def sendevent(self, name, **kwargs): def sendevent(self, name, **kwargs):
self.log("sending", name, kwargs) self.log("sending", name, kwargs)
self.channel.send((name, kwargs)) self.channel.send((name, kwargs))
@@ -71,38 +110,63 @@ class WorkerInteractor:
def pytest_collection(self, session): def pytest_collection(self, session):
self.sendevent("collectionstart") self.sendevent("collectionstart")
def handle_command(self, command):
if command is self.SHUTDOWN_MARK:
self.torun.put(self.SHUTDOWN_MARK)
return
name, kwargs = command
self.log("received command", name, kwargs)
if name == "runtests":
for i in kwargs["indices"]:
self.torun.put(i)
elif name == "runtests_all":
for i in range(len(self.session.items)):
self.torun.put(i)
elif name == "shutdown":
self.torun.put(self.SHUTDOWN_MARK)
elif name == "steal":
self.steal(kwargs["indices"])
def steal(self, indices):
indices = set(indices)
stolen = []
old_queue, self.torun = self.torun, self._make_queue()
def old_queue_get_nowait_noraise():
with contextlib.suppress(self.channel.gateway.execmodel.queue.Empty):
return old_queue.get_nowait()
for i in iter(old_queue_get_nowait_noraise, None):
if i in indices:
stolen.append(i)
else:
self.torun.put(i)
self.sendevent("unscheduled", indices=stolen)
old_queue.put(self.QUEUE_REPLACED_MARK)
@pytest.hookimpl @pytest.hookimpl
def pytest_runtestloop(self, session): def pytest_runtestloop(self, session):
self.log("entering main loop") self.log("entering main loop")
torun = [] self.channel.setcallback(self.handle_command, endmarker=self.SHUTDOWN_MARK)
while 1: self.nextitem_index = self._get_next_item_index()
try: while self.nextitem_index is not self.SHUTDOWN_MARK:
name, kwargs = self.channel.receive() self.run_one_test()
except EOFError:
return True
self.log("received command", name, kwargs)
if name == "runtests":
torun.extend(kwargs["indices"])
elif name == "runtests_all":
torun.extend(range(len(session.items)))
self.log("items to run:", torun)
# only run if we have an item and a next item
while len(torun) >= 2:
self.run_one_test(torun)
if name == "shutdown":
if torun:
self.run_one_test(torun)
break
return True return True
def run_one_test(self, torun): def run_one_test(self):
self.item_index = self.nextitem_index
self.nextitem_index = self._get_next_item_index()
items = self.session.items items = self.session.items
self.item_index = torun.pop(0)
item = items[self.item_index] item = items[self.item_index]
if torun: if self.nextitem_index is self.SHUTDOWN_MARK:
nextitem = items[torun[0]]
else:
nextitem = None nextitem = None
else:
nextitem = items[self.nextitem_index]
worker_title("[pytest-xdist running] %s" % item.nodeid) worker_title("[pytest-xdist running] %s" % item.nodeid)
@@ -128,7 +192,7 @@ class WorkerInteractor:
if len(mark.args) > 0 if len(mark.args) > 0
else mark.kwargs.get("name", "default") else mark.kwargs.get("name", "default")
) )
item._nodeid = "{}@{}".format(item.nodeid, gname) item._nodeid = f"{item.nodeid}@{gname}"
@pytest.hookimpl @pytest.hookimpl
def pytest_collection_finish(self, session): def pytest_collection_finish(self, session):

View File

@@ -14,7 +14,8 @@ def report_collection_diff(from_collection, to_collection, from_id, to_id):
error_message = ( error_message = (
"Different tests were collected between {from_id} and {to_id}. " "Different tests were collected between {from_id} and {to_id}. "
"The difference is:\n" "The difference is:\n"
"{diff}" "{diff}\n"
"To see why this happens see Known limitations in documentation"
).format(from_id=from_id, to_id=to_id, diff="\n".join(diff)) ).format(from_id=from_id, to_id=to_id, diff="\n".join(diff))
msg = "\n".join(x.rstrip() for x in error_message.split("\n")) msg = "\n".join(x.rstrip() for x in error_message.split("\n"))
return msg return msg

View File

@@ -3,3 +3,4 @@ from xdist.scheduler.load import LoadScheduling # noqa
from xdist.scheduler.loadfile import LoadFileScheduling # noqa from xdist.scheduler.loadfile import LoadFileScheduling # noqa
from xdist.scheduler.loadscope import LoadScopeScheduling # noqa from xdist.scheduler.loadscope import LoadScopeScheduling # noqa
from xdist.scheduler.loadgroup import LoadGroupScheduling # noqa from xdist.scheduler.loadgroup import LoadGroupScheduling # noqa
from xdist.scheduler.worksteal import WorkStealingScheduling # noqa

View File

@@ -1,5 +1,4 @@
from py.log import Producer from xdist.remote import Producer
from xdist.workermanage import parse_spec_config from xdist.workermanage import parse_spec_config
from xdist.report import report_collection_diff from xdist.report import report_collection_diff

View File

@@ -1,8 +1,8 @@
from itertools import cycle from itertools import cycle
from py.log import Producer
from _pytest.runner import CollectReport from _pytest.runner import CollectReport
from xdist.remote import Producer
from xdist.workermanage import parse_spec_config from xdist.workermanage import parse_spec_config
from xdist.report import report_collection_diff from xdist.report import report_collection_diff
@@ -64,6 +64,7 @@ class LoadScheduling:
else: else:
self.log = log.loadsched self.log = log.loadsched
self.config = config self.config = config
self.maxschedchunk = self.config.getoption("maxschedchunk")
@property @property
def nodes(self): def nodes(self):
@@ -185,7 +186,9 @@ class LoadScheduling:
# so let's rather wait with sending new items # so let's rather wait with sending new items
return return
num_send = items_per_node_max - len(node_pending) num_send = items_per_node_max - len(node_pending)
self._send_tests(node, num_send) # keep at least 2 tests pending even if --maxschedchunk=1
maxschedchunk = max(2 - len(node_pending), self.maxschedchunk)
self._send_tests(node, min(num_send, maxschedchunk))
else: else:
node.shutdown() node.shutdown()
@@ -245,16 +248,34 @@ class LoadScheduling:
if not self.collection: if not self.collection:
return return
if self.maxschedchunk is None:
self.maxschedchunk = len(self.collection)
# Send a batch of tests to run. If we don't have at least two # Send a batch of tests to run. If we don't have at least two
# tests per node, we have to send them all so that we can send # tests per node, we have to send them all so that we can send
# shutdown signals and get all nodes working. # shutdown signals and get all nodes working.
initial_batch = max(len(self.pending) // 4, 2 * len(self.nodes)) if len(self.pending) < 2 * len(self.nodes):
# Distribute tests round-robin. Try to load all nodes if there are
# enough tests. The other branch tries sends at least 2 tests
# to each node - which is suboptimal when you have less than
# 2 * len(nodes) tests.
nodes = cycle(self.nodes)
for i in range(len(self.pending)):
self._send_tests(next(nodes), 1)
else:
# Send batches of consecutive tests. By default, pytest sorts tests
# in order for optimal single-threaded execution, minimizing the
# number of necessary fixture setup/teardown. Try to keep that
# optimal order for every worker.
# distribute tests round-robin up to the batch size # how many items per node do we have about?
# (or until we run out) items_per_node = len(self.collection) // len(self.node2pending)
nodes = cycle(self.nodes) # take a fraction of tests for initial distribution
for i in range(initial_batch): node_chunksize = min(items_per_node // 4, self.maxschedchunk)
self._send_tests(next(nodes), 1) node_chunksize = max(node_chunksize, 2)
# and initialize each node with a chunk of tests
for node in self.nodes:
self._send_tests(node, node_chunksize)
if not self.pending: if not self.pending:
# initial distribution sent all tests, start node shutdown # initial distribution sent all tests, start node shutdown

View File

@@ -1,5 +1,5 @@
from .loadscope import LoadScopeScheduling from .loadscope import LoadScopeScheduling
from py.log import Producer from xdist.remote import Producer
class LoadFileScheduling(LoadScopeScheduling): class LoadFileScheduling(LoadScopeScheduling):

View File

@@ -1,5 +1,5 @@
from .loadscope import LoadScopeScheduling from .loadscope import LoadScopeScheduling
from py.log import Producer from xdist.remote import Producer
class LoadGroupScheduling(LoadScopeScheduling): class LoadGroupScheduling(LoadScopeScheduling):

View File

@@ -1,7 +1,7 @@
from collections import OrderedDict from collections import OrderedDict
from _pytest.runner import CollectReport from _pytest.runner import CollectReport
from py.log import Producer from xdist.remote import Producer
from xdist.report import report_collection_diff from xdist.report import report_collection_diff
from xdist.workermanage import parse_spec_config from xdist.workermanage import parse_spec_config
@@ -213,13 +213,11 @@ class LoadScopeScheduling:
# A new node has been added later, perhaps an original one died. # A new node has been added later, perhaps an original one died.
if self.collection_is_completed: if self.collection_is_completed:
# Assert that .schedule() should have been called by now # Assert that .schedule() should have been called by now
assert self.collection assert self.collection
# Check that the new collection matches the official collection # Check that the new collection matches the official collection
if collection != self.collection: if collection != self.collection:
other_node = next(iter(self.registered_collections.keys())) other_node = next(iter(self.registered_collections.keys()))
msg = report_collection_diff( msg = report_collection_diff(
@@ -361,12 +359,12 @@ class LoadScopeScheduling:
extra_nodes = len(self.nodes) - len(self.workqueue) extra_nodes = len(self.nodes) - len(self.workqueue)
if extra_nodes > 0: if extra_nodes > 0:
self.log("Shuting down {} nodes".format(extra_nodes)) self.log(f"Shutting down {extra_nodes} nodes")
for _ in range(extra_nodes): for _ in range(extra_nodes):
unused_node, assigned = self.assigned_work.popitem(last=True) unused_node, assigned = self.assigned_work.popitem(last=True)
self.log("Shuting down unused node {}".format(unused_node)) self.log(f"Shutting down unused node {unused_node}")
unused_node.shutdown() unused_node.shutdown()
# Assign initial workload # Assign initial workload

View File

@@ -0,0 +1,325 @@
from collections import namedtuple
from _pytest.runner import CollectReport
from xdist.remote import Producer
from xdist.workermanage import parse_spec_config
from xdist.report import report_collection_diff
NodePending = namedtuple("NodePending", ["node", "pending"])
# Every worker needs at least 2 tests in queue - the current and the next one.
MIN_PENDING = 2
class WorkStealingScheduling:
"""Implement work-stealing scheduling.
Initially, tests are distributed evenly among all nodes.
When some node completes most of its assigned tests (when only one pending
test remains), an attempt is made to reassign ("steal") some tests from
other nodes to this node.
Attributes:
:numnodes: The expected number of nodes taking part. The actual
number of nodes will vary during the scheduler's lifetime as
nodes are added by the DSession as they are brought up and
removed either because of a dead node or normal shutdown. This
number is primarily used to know when the initial collection is
completed.
:node2collection: Map of nodes and their test collection. All
collections should always be identical.
:node2pending: Map of nodes and the indices of their pending
tests. The indices are an index into ``.pending`` (which is
identical to their own collection stored in
``.node2collection``).
:collection: The one collection once it is validated to be
identical between all the nodes. It is initialised to None
until ``.schedule()`` is called.
:pending: List of indices of globally pending tests. These are
tests which have not yet been allocated to a chunk for a node
to process.
:log: A py.log.Producer instance.
:config: Config object, used for handling hooks.
:steal_requested_from_node: The node to which the current "steal" request
was sent. ``None`` if there is no request in progress. Only one request
can be in progress at any time, the scheduler doesn't send multiple
simultaneous requests.
"""
def __init__(self, config, log=None):
self.numnodes = len(parse_spec_config(config))
self.node2collection = {}
self.node2pending = {}
self.pending = []
self.collection = None
if log is None:
self.log = Producer("workstealsched")
else:
self.log = log.workstealsched
self.config = config
self.steal_requested_from_node = None
@property
def nodes(self):
"""A list of all nodes in the scheduler."""
return list(self.node2pending.keys())
@property
def collection_is_completed(self):
"""Boolean indication initial test collection is complete.
This is a boolean indicating all initial participating nodes
have finished collection. The required number of initial
nodes is defined by ``.numnodes``.
"""
return len(self.node2collection) >= self.numnodes
@property
def tests_finished(self):
"""Return True if all tests have been executed by the nodes."""
if not self.collection_is_completed:
return False
if self.pending:
return False
if self.steal_requested_from_node is not None:
return False
for pending in self.node2pending.values():
if len(pending) >= MIN_PENDING:
return False
return True
@property
def has_pending(self):
"""Return True if there are pending test items
This indicates that collection has finished and nodes are
still processing test items, so this can be thought of as
"the scheduler is active".
"""
if self.pending:
return True
for pending in self.node2pending.values():
if pending:
return True
return False
def add_node(self, node):
"""Add a new node to the scheduler.
From now on the node will be allocated chunks of tests to
execute.
Called by the ``DSession.worker_workerready`` hook when it
successfully bootstraps a new node.
"""
assert node not in self.node2pending
self.node2pending[node] = []
def add_node_collection(self, node, collection):
"""Add the collected test items from a node
The collection is stored in the ``.node2collection`` map.
Called by the ``DSession.worker_collectionfinish`` hook.
"""
assert node in self.node2pending
if self.collection_is_completed:
# A new node has been added later, perhaps an original one died.
# .schedule() should have
# been called by now
assert self.collection
if collection != self.collection:
other_node = next(iter(self.node2collection.keys()))
msg = report_collection_diff(
self.collection, collection, other_node.gateway.id, node.gateway.id
)
self.log(msg)
return
self.node2collection[node] = list(collection)
def mark_test_complete(self, node, item_index, duration=None):
"""Mark test item as completed by node
This is called by the ``DSession.worker_testreport`` hook.
"""
self.node2pending[node].remove(item_index)
self.check_schedule()
def mark_test_pending(self, item):
self.pending.insert(
0,
self.collection.index(item),
)
self.check_schedule()
def remove_pending_tests_from_node(self, node, indices):
"""Node returned some test indices back in response to 'steal' command.
This is called by ``DSession.worker_unscheduled``.
"""
assert node is self.steal_requested_from_node
self.steal_requested_from_node = None
indices_set = set(indices)
self.node2pending[node] = [
i for i in self.node2pending[node] if i not in indices_set
]
self.pending.extend(indices)
self.check_schedule()
def check_schedule(self):
"""Reschedule tests/perform load balancing."""
nodes_up = [
NodePending(node, pending)
for node, pending in self.node2pending.items()
if not node.shutting_down
]
def get_idle_nodes():
return [node for node, pending in nodes_up if len(pending) < MIN_PENDING]
idle_nodes = get_idle_nodes()
if not idle_nodes:
return
if self.pending:
# Distribute pending tests evenly among idle nodes
for i, node in enumerate(idle_nodes):
nodes_remaining = len(idle_nodes) - i
num_send = len(self.pending) // nodes_remaining
self._send_tests(node, num_send)
idle_nodes = get_idle_nodes()
# No need to steal anything if all nodes have enough work to continue
if not idle_nodes:
return
# Only one active stealing request is allowed
if self.steal_requested_from_node is not None:
return
# Find the node that has the longest test queue
steal_from = max(
nodes_up, key=lambda node_pending: len(node_pending.pending), default=None
)
if steal_from is None:
num_steal = 0
else:
# Steal half of the test queue - but keep that node running too.
# If the node has 2 or less tests queued, stealing will fail
# anyway.
max_steal = max(0, len(steal_from.pending) - MIN_PENDING)
num_steal = min(len(steal_from.pending) // 2, max_steal)
if num_steal == 0:
# Can't get more work - shutdown idle nodes. This will force them
# to run the last test now instead of waiting for more tests.
for node in idle_nodes:
node.shutdown()
return
steal_from.node.send_steal(steal_from.pending[-num_steal:])
self.steal_requested_from_node = steal_from.node
def remove_node(self, node):
"""Remove a node from the scheduler
This should be called either when the node crashed or at
shutdown time. In the former case any pending items assigned
to the node will be re-scheduled. Called by the
``DSession.worker_workerfinished`` and
``DSession.worker_errordown`` hooks.
Return the item which was being executing while the node
crashed or None if the node has no more pending items.
"""
pending = self.node2pending.pop(node)
# If node was removed without completing its assigned tests - it crashed
if pending:
crashitem = self.collection[pending.pop(0)]
else:
crashitem = None
self.pending.extend(pending)
# Dead node won't respond to "steal" request
if self.steal_requested_from_node is node:
self.steal_requested_from_node = None
self.check_schedule()
return crashitem
def schedule(self):
"""Initiate distribution of the test collection
Initiate scheduling of the items across the nodes. If this
gets called again later it behaves the same as calling
``.check_schedule()`` on all nodes so that newly added nodes
will start to be used.
This is called by the ``DSession.worker_collectionfinish`` hook
if ``.collection_is_completed`` is True.
"""
assert self.collection_is_completed
# Initial distribution already happened, reschedule on all nodes
if self.collection is not None:
self.check_schedule()
return
if not self._check_nodes_have_same_collection():
self.log("**Different tests collected, aborting run**")
return
# Collections are identical, create the index of pending items.
self.collection = list(self.node2collection.values())[0]
self.pending[:] = range(len(self.collection))
if not self.collection:
return
self.check_schedule()
def _send_tests(self, node, num):
tests_per_node = self.pending[:num]
if tests_per_node:
del self.pending[:num]
self.node2pending[node].extend(tests_per_node)
node.send_runtest_some(tests_per_node)
def _check_nodes_have_same_collection(self):
"""Return True if all nodes have collected the same items.
If collections differ, this method returns False while logging
the collection differences and posting collection errors to
pytest_collectreport hook.
"""
node_collection_items = list(self.node2collection.items())
first_node, col = node_collection_items[0]
same_collection = True
for node, collection in node_collection_items[1:]:
msg = report_collection_diff(
col, collection, first_node.gateway.id, node.gateway.id
)
if msg:
same_collection = False
self.log(msg)
if self.config is not None:
rep = CollectReport(
node.gateway.id, "failed", longrepr=msg, result=[]
)
self.config.hook.pytest_collectreport(report=rep)
return same_collection

View File

@@ -3,12 +3,14 @@ import os
import re import re
import sys import sys
import uuid import uuid
from pathlib import Path
from typing import List, Union, Sequence, Optional, Any, Tuple, Set
import py
import pytest import pytest
import execnet import execnet
import xdist.remote import xdist.remote
from xdist.remote import Producer
from xdist.plugin import _sys_path from xdist.plugin import _sys_path
@@ -33,7 +35,7 @@ class NodeManager:
EXIT_TIMEOUT = 10 EXIT_TIMEOUT = 10
DEFAULT_IGNORES = [".*", "*.pyc", "*.pyo", "*~"] DEFAULT_IGNORES = [".*", "*.pyc", "*.pyo", "*~"]
def __init__(self, config, specs=None, defaultchdir="pyexecnetcache"): def __init__(self, config, specs=None, defaultchdir="pyexecnetcache") -> None:
self.config = config self.config = config
self.trace = self.config.trace.get("nodemanager") self.trace = self.config.trace.get("nodemanager")
self.testrunuid = self.config.getoption("testrunuid") self.testrunuid = self.config.getoption("testrunuid")
@@ -52,7 +54,7 @@ class NodeManager:
self.specs.append(spec) self.specs.append(spec)
self.roots = self._getrsyncdirs() self.roots = self._getrsyncdirs()
self.rsyncoptions = self._getrsyncoptions() self.rsyncoptions = self._getrsyncoptions()
self._rsynced_specs = set() self._rsynced_specs: Set[Tuple[Any, Any]] = set()
def rsync_roots(self, gateway): def rsync_roots(self, gateway):
"""Rsync the set of roots to the node's gateway cwd.""" """Rsync the set of roots to the node's gateway cwd."""
@@ -81,7 +83,7 @@ class NodeManager:
def _getxspecs(self): def _getxspecs(self):
return [execnet.XSpec(x) for x in parse_spec_config(self.config)] return [execnet.XSpec(x) for x in parse_spec_config(self.config)]
def _getrsyncdirs(self): def _getrsyncdirs(self) -> List[Path]:
for spec in self.specs: for spec in self.specs:
if not spec.popen or spec.chdir: if not spec.popen or spec.chdir:
break break
@@ -101,16 +103,16 @@ class NodeManager:
pytestpath = get_dir(pytest.__file__) pytestpath = get_dir(pytest.__file__)
pytestdir = get_dir(_pytest.__file__) pytestdir = get_dir(_pytest.__file__)
config = self.config config = self.config
candidates = [py._pydir, pytestpath, pytestdir] candidates = [pytestpath, pytestdir]
candidates += config.option.rsyncdir candidates += config.option.rsyncdir
rsyncroots = config.getini("rsyncdirs") rsyncroots = config.getini("rsyncdirs")
if rsyncroots: if rsyncroots:
candidates.extend(rsyncroots) candidates.extend(rsyncroots)
roots = [] roots = []
for root in candidates: for root in candidates:
root = py.path.local(root).realpath() root = Path(root).resolve()
if not root.check(): if not root.exists():
raise pytest.UsageError("rsyncdir doesn't exist: {!r}".format(root)) raise pytest.UsageError(f"rsyncdir doesn't exist: {root!r}")
if root not in roots: if root not in roots:
roots.append(root) roots.append(root)
return roots return roots
@@ -160,18 +162,24 @@ class NodeManager:
class HostRSync(execnet.RSync): class HostRSync(execnet.RSync):
"""RSyncer that filters out common files""" """RSyncer that filters out common files"""
def __init__(self, sourcedir, *args, **kwargs): PathLike = Union[str, "os.PathLike[str]"]
self._synced = {}
ignores = kwargs.pop("ignores", None) or []
self._ignores = [
re.compile(fnmatch.translate(getattr(x, "strpath", x))) for x in ignores
]
super().__init__(sourcedir=sourcedir, **kwargs)
def filter(self, path): def __init__(
path = py.path.local(path) self,
sourcedir: PathLike,
*,
ignores: Optional[Sequence[PathLike]] = None,
**kwargs: object
) -> None:
if ignores is None:
ignores = []
self._ignores = [re.compile(fnmatch.translate(os.fspath(x))) for x in ignores]
super().__init__(sourcedir=Path(sourcedir), **kwargs)
def filter(self, path: PathLike) -> bool:
path = Path(path)
for cre in self._ignores: for cre in self._ignores:
if cre.match(path.basename) or cre.match(path.strpath): if cre.match(path.name) or cre.match(str(path)):
return False return False
else: else:
return True return True
@@ -184,26 +192,34 @@ class HostRSync(execnet.RSync):
if self._verbose > 0: if self._verbose > 0:
path = os.path.basename(self._sourcedir) + "/" + modified_rel_path path = os.path.basename(self._sourcedir) + "/" + modified_rel_path
remotepath = gateway.spec.chdir remotepath = gateway.spec.chdir
print("{}:{} <= {}".format(gateway.spec, remotepath, path)) print(f"{gateway.spec}:{remotepath} <= {path}")
def make_reltoroot(roots, args): def make_reltoroot(roots: Sequence[Path], args: List[str]) -> List[str]:
# XXX introduce/use public API for splitting pytest args # XXX introduce/use public API for splitting pytest args
splitcode = "::" splitcode = "::"
result = [] result = []
for arg in args: for arg in args:
parts = arg.split(splitcode) parts = arg.split(splitcode)
fspath = py.path.local(parts[0]) fspath = Path(parts[0])
if not fspath.exists(): try:
exists = fspath.exists()
except OSError:
exists = False
if not exists:
result.append(arg) result.append(arg)
continue continue
for root in roots: for root in roots:
x = fspath.relto(root) x: Optional[Path]
try:
x = fspath.relative_to(root)
except ValueError:
x = None
if x or fspath == root: if x or fspath == root:
parts[0] = root.basename + "/" + x parts[0] = root.name + "/" + str(x)
break break
else: else:
raise ValueError("arg {} not relative to an rsync root".format(arg)) raise ValueError(f"arg {arg} not relative to an rsync root")
result.append(splitcode.join(parts)) result.append(splitcode.join(parts))
return result return result
@@ -230,12 +246,10 @@ class WorkerController:
} }
self._down = False self._down = False
self._shutdown_sent = False self._shutdown_sent = False
self.log = py.log.Producer("workerctl-%s" % gateway.id) self.log = Producer(f"workerctl-{gateway.id}", enabled=config.option.debug)
if not self.config.option.debug:
py.log.setconsumer(self.log._keywords, None)
def __repr__(self): def __repr__(self):
return "<{} {}>".format(self.__class__.__name__, self.gateway.id) return f"<{self.__class__.__name__} {self.gateway.id}>"
@property @property
def shutting_down(self): def shutting_down(self):
@@ -286,6 +300,9 @@ class WorkerController:
def send_runtest_all(self): def send_runtest_all(self):
self.sendcommand("runtests_all") self.sendcommand("runtests_all")
def send_steal(self, indices):
self.sendcommand("steal", indices=indices)
def shutdown(self): def shutdown(self):
if not self._down: if not self._down:
try: try:
@@ -296,11 +313,11 @@ class WorkerController:
def sendcommand(self, name, **kwargs): def sendcommand(self, name, **kwargs):
"""send a named parametrized command to the other side.""" """send a named parametrized command to the other side."""
self.log("sending command {}(**{})".format(name, kwargs)) self.log(f"sending command {name}(**{kwargs})")
self.channel.send((name, kwargs)) self.channel.send((name, kwargs))
def notify_inproc(self, eventname, **kwargs): def notify_inproc(self, eventname, **kwargs):
self.log("queuing {}(**{})".format(eventname, kwargs)) self.log(f"queuing {eventname}(**{kwargs})")
self.putevent((eventname, kwargs)) self.putevent((eventname, kwargs))
def process_from_remote(self, eventcall): # noqa too complex def process_from_remote(self, eventcall): # noqa too complex
@@ -322,7 +339,7 @@ class WorkerController:
return return
eventname, kwargs = eventcall eventname, kwargs = eventcall
if eventname in ("collectionstart",): if eventname in ("collectionstart",):
self.log("ignoring {}({})".format(eventname, kwargs)) self.log(f"ignoring {eventname}({kwargs})")
elif eventname == "workerready": elif eventname == "workerready":
self.notify_inproc(eventname, node=self, **kwargs) self.notify_inproc(eventname, node=self, **kwargs)
elif eventname == "internal_error": elif eventname == "internal_error":
@@ -345,6 +362,8 @@ class WorkerController:
self.notify_inproc(eventname, node=self, ids=kwargs["ids"]) self.notify_inproc(eventname, node=self, ids=kwargs["ids"])
elif eventname == "runtest_protocol_complete": elif eventname == "runtest_protocol_complete":
self.notify_inproc(eventname, node=self, **kwargs) self.notify_inproc(eventname, node=self, **kwargs)
elif eventname == "unscheduled":
self.notify_inproc(eventname, node=self, **kwargs)
elif eventname == "logwarning": elif eventname == "logwarning":
self.notify_inproc( self.notify_inproc(
eventname, eventname,
@@ -375,7 +394,7 @@ class WorkerController:
location=kwargs["location"], location=kwargs["location"],
) )
else: else:
raise ValueError("unknown event: {}".format(eventname)) raise ValueError(f"unknown event: {eventname}")
except KeyboardInterrupt: except KeyboardInterrupt:
# should not land in receiver-thread # should not land in receiver-thread
raise raise

View File

@@ -101,7 +101,12 @@ class TestDistribution:
""" """
) )
result = pytester.runpytest(p1, "-v", "-d", "--tx=popen", "--tx=popen") result = pytester.runpytest(p1, "-v", "-d", "--tx=popen", "--tx=popen")
result.stdout.fnmatch_lines(["*1*Python*", "*2 failed, 1 passed, 1 skipped*"]) result.stdout.fnmatch_lines(
[
"created: 2/2 workers",
"*2 failed, 1 passed, 1 skipped*",
]
)
assert result.ret == 1 assert result.ret == 1
def test_n1_fail_minus_x(self, pytester: pytest.Pytester) -> None: def test_n1_fail_minus_x(self, pytester: pytest.Pytester) -> None:
@@ -151,10 +156,14 @@ class TestDistribution:
""" """
) )
result = pytester.runpytest(p1, "-d", "-v") result = pytester.runpytest(p1, "-d", "-v")
result.stdout.fnmatch_lines(["*2*Python*", "*2 failed, 1 passed, 1 skipped*"]) result.stdout.fnmatch_lines(
[
"created: 3/3 workers",
"*2 failed, 1 passed, 1 skipped*",
]
)
assert result.ret == 1 assert result.ret == 1
@pytest.mark.xfail("sys.platform.startswith('java')", run=False)
def test_dist_tests_with_crash(self, pytester: pytest.Pytester) -> None: def test_dist_tests_with_crash(self, pytester: pytest.Pytester) -> None:
if not hasattr(os, "kill"): if not hasattr(os, "kill"):
pytest.skip("no os.kill") pytest.skip("no os.kill")
@@ -238,9 +247,6 @@ class TestDistribution:
assert result.ret == 0 assert result.ret == 0
result.stdout.fnmatch_lines( result.stdout.fnmatch_lines(
[ [
"*0* *cwd*",
# "RSyncStart: [G1]",
# "RSyncFinished: [G1]",
"*1 passed*", "*1 passed*",
] ]
) )
@@ -277,7 +283,11 @@ class TestDistribution:
p1 = pytester.makepyfile("def test_func(): pass") p1 = pytester.makepyfile("def test_func(): pass")
result = pytester.runpytest("-v", p1, "-d", "--tx=popen") result = pytester.runpytest("-v", p1, "-d", "--tx=popen")
result.stdout.fnmatch_lines( result.stdout.fnmatch_lines(
["*0*Python*", "*calculated result is 49*", "*1 passed*"] [
"created: 1/1 worker",
"*calculated result is 49*",
"*1 passed*",
]
) )
assert result.ret == 0 assert result.ret == 0
@@ -394,14 +404,14 @@ class TestTerminalReporting:
out = result.stdout.str() out = result.stdout.str()
if verbosity == "-v": if verbosity == "-v":
assert "scheduling tests" in out assert "scheduling tests" in out
assert "gw" in out assert "1 worker [1 item]" in out
elif verbosity == "-q": elif verbosity == "-q":
assert "scheduling tests" not in out assert "scheduling tests" not in out
assert "gw" not in out assert "gw" not in out
assert "bringing up nodes..." in out assert "bringing up nodes..." in out
else: else:
assert "scheduling tests" not in out assert "scheduling tests" not in out
assert "gw" in out assert "1 worker [1 item]" in out
def test_pass_skip_fail(self, pytester: pytest.Pytester) -> None: def test_pass_skip_fail(self, pytester: pytest.Pytester) -> None:
pytester.makepyfile( pytester.makepyfile(
@@ -603,11 +613,10 @@ def test_config_initialization(
def test_crashing_item(pytester, when) -> None: def test_crashing_item(pytester, when) -> None:
"""Ensure crashing item is correctly reported during all testing stages""" """Ensure crashing item is correctly reported during all testing stages"""
code = dict(setup="", call="", teardown="") code = dict(setup="", call="", teardown="")
code[when] = "py.process.kill(os.getpid())" code[when] = "os._exit(1)"
p = pytester.makepyfile( p = pytester.makepyfile(
""" """
import os import os
import py
import pytest import pytest
@pytest.fixture @pytest.fixture
@@ -742,7 +751,7 @@ def test_sub_plugins_disabled(pytester, plugin) -> None:
pass pass
""" """
) )
result = pytester.runpytest(p1, "-n1", "-p", "no:%s" % plugin) result = pytester.runpytest(p1, "-n1", "-p", f"no:{plugin}")
assert result.ret == 0 assert result.ret == 0
result.stdout.fnmatch_lines("*1 passed*") result.stdout.fnmatch_lines("*1 passed*")
@@ -1101,8 +1110,9 @@ def test_color_yes_collection_on_non_atty(pytester, request) -> None:
result = pytester.runpytest(*args) result = pytester.runpytest(*args)
assert "test session starts" in result.stdout.str() assert "test session starts" in result.stdout.str()
assert "\x1b[1m" in result.stdout.str() assert "\x1b[1m" in result.stdout.str()
assert "gw0 [10] / gw1 [10]" in result.stdout.str() assert "created: 2/2 workers" in result.stdout.str()
assert "gw0 C / gw1 C" not in result.stdout.str() assert "2 workers [10 items]" in result.stdout.str()
assert "collecting:" not in result.stdout.str()
def test_without_terminal_plugin(pytester, request) -> None: def test_without_terminal_plugin(pytester, request) -> None:
@@ -1245,22 +1255,14 @@ class TestFileScope:
"test_b.py::TestB", result.outlines "test_b.py::TestB", result.outlines
) )
assert ( assert test_a_workers_and_test_count in (
test_a_workers_and_test_count {"gw0": 10},
in ( {"gw1": 0},
{"gw0": 10}, ) or test_a_workers_and_test_count in ({"gw0": 0}, {"gw1": 10})
{"gw1": 0}, assert test_b_workers_and_test_count in (
) {"gw0": 10},
or test_a_workers_and_test_count in ({"gw0": 0}, {"gw1": 10}) {"gw1": 0},
) ) or test_b_workers_and_test_count in ({"gw0": 0}, {"gw1": 10})
assert (
test_b_workers_and_test_count
in (
{"gw0": 10},
{"gw1": 0},
)
or test_b_workers_and_test_count in ({"gw0": 0}, {"gw1": 10})
)
def test_by_class(self, pytester: pytest.Pytester) -> None: def test_by_class(self, pytester: pytest.Pytester) -> None:
pytester.makepyfile( pytester.makepyfile(
@@ -1285,22 +1287,14 @@ class TestFileScope:
"test_a.py::TestB", result.outlines "test_a.py::TestB", result.outlines
) )
assert ( assert test_a_workers_and_test_count in (
test_a_workers_and_test_count {"gw0": 10},
in ( {"gw1": 0},
{"gw0": 10}, ) or test_a_workers_and_test_count in ({"gw0": 0}, {"gw1": 10})
{"gw1": 0}, assert test_b_workers_and_test_count in (
) {"gw0": 10},
or test_a_workers_and_test_count in ({"gw0": 0}, {"gw1": 10}) {"gw1": 0},
) ) or test_b_workers_and_test_count in ({"gw0": 0}, {"gw1": 10})
assert (
test_b_workers_and_test_count
in (
{"gw0": 10},
{"gw1": 0},
)
or test_b_workers_and_test_count in ({"gw0": 0}, {"gw1": 10})
)
def test_module_single_start(self, pytester: pytest.Pytester) -> None: def test_module_single_start(self, pytester: pytest.Pytester) -> None:
"""Fix test suite never finishing in case all workers start with a single test (#277).""" """Fix test suite never finishing in case all workers start with a single test (#277)."""
@@ -1347,22 +1341,14 @@ class TestGroupScope:
"test_b.py::TestA", result.outlines "test_b.py::TestA", result.outlines
) )
assert ( assert test_a_workers_and_test_count in (
test_a_workers_and_test_count {"gw0": 5},
in ( {"gw1": 0},
{"gw0": 5}, ) or test_a_workers_and_test_count in ({"gw0": 0}, {"gw1": 5})
{"gw1": 0}, assert test_b_workers_and_test_count in (
) {"gw0": 5},
or test_a_workers_and_test_count in ({"gw0": 0}, {"gw1": 5}) {"gw1": 0},
) ) or test_b_workers_and_test_count in ({"gw0": 0}, {"gw1": 5})
assert (
test_b_workers_and_test_count
in (
{"gw0": 5},
{"gw1": 0},
)
or test_b_workers_and_test_count in ({"gw0": 0}, {"gw1": 5})
)
assert ( assert (
test_a_workers_and_test_count.items() test_a_workers_and_test_count.items()
== test_b_workers_and_test_count.items() == test_b_workers_and_test_count.items()
@@ -1392,22 +1378,14 @@ class TestGroupScope:
"test_a.py::TestB", result.outlines "test_a.py::TestB", result.outlines
) )
assert ( assert test_a_workers_and_test_count in (
test_a_workers_and_test_count {"gw0": 10},
in ( {"gw1": 0},
{"gw0": 10}, ) or test_a_workers_and_test_count in ({"gw0": 0}, {"gw1": 10})
{"gw1": 0}, assert test_b_workers_and_test_count in (
) {"gw0": 10},
or test_a_workers_and_test_count in ({"gw0": 0}, {"gw1": 10}) {"gw1": 0},
) ) or test_b_workers_and_test_count in ({"gw0": 0}, {"gw1": 10})
assert (
test_b_workers_and_test_count
in (
{"gw0": 10},
{"gw1": 0},
)
or test_b_workers_and_test_count in ({"gw0": 0}, {"gw1": 10})
)
assert ( assert (
test_a_workers_and_test_count.items() test_a_workers_and_test_count.items()
== test_b_workers_and_test_count.items() == test_b_workers_and_test_count.items()
@@ -1576,3 +1554,40 @@ class TestAPI:
assert xdist.get_xdist_worker_id(fake_request) == "gw5" assert xdist.get_xdist_worker_id(fake_request) == "gw5"
del fake_request.config.workerinput del fake_request.config.workerinput
assert xdist.get_xdist_worker_id(fake_request) == "master" assert xdist.get_xdist_worker_id(fake_request) == "master"
def test_collection_crash(testdir):
p1 = testdir.makepyfile(
"""
assert 0
"""
)
result = testdir.runpytest(p1, "-n1")
assert result.ret == 1
result.stdout.fnmatch_lines(
[
"created: 1/1 worker",
"1 worker [[]0 items[]]",
"*_ ERROR collecting test_collection_crash.py _*",
"E assert 0",
"*= 1 error in *",
]
)
def test_dist_in_addopts(testdir):
"""Users can set a default distribution in the configuration file (#789)."""
testdir.makepyfile(
"""
def test():
pass
"""
)
testdir.makeini(
"""
[pytest]
addopts = --dist loadscope
"""
)
result = testdir.runpytest()
assert result.ret == 0

View File

@@ -1,7 +1,13 @@
from xdist.dsession import DSession, get_default_max_worker_restart from __future__ import annotations
from xdist.dsession import (
DSession,
get_default_max_worker_restart,
get_workers_status_line,
WorkerStatus,
)
from xdist.report import report_collection_diff from xdist.report import report_collection_diff
from xdist.scheduler import EachScheduling, LoadScheduling from xdist.scheduler import EachScheduling, LoadScheduling, WorkStealingScheduling
from typing import Optional from typing import Sequence
import pytest import pytest
import execnet import execnet
@@ -17,6 +23,7 @@ class MockGateway:
class MockNode: class MockNode:
def __init__(self) -> None: def __init__(self) -> None:
self.sent = [] # type: ignore[var-annotated] self.sent = [] # type: ignore[var-annotated]
self.stolen = [] # type: ignore[var-annotated]
self.gateway = MockGateway() self.gateway = MockGateway()
self._shutdown = False self._shutdown = False
@@ -26,6 +33,9 @@ class MockNode:
def send_runtest_all(self) -> None: def send_runtest_all(self) -> None:
self.sent.append("ALL") self.sent.append("ALL")
def send_steal(self, indices) -> None:
self.stolen.extend(indices)
def shutdown(self) -> None: def shutdown(self) -> None:
self._shutdown = True self._shutdown = True
@@ -115,44 +125,93 @@ class TestLoadScheduling:
# assert not sched.tests_finished # assert not sched.tests_finished
sent1 = node1.sent sent1 = node1.sent
sent2 = node2.sent sent2 = node2.sent
assert sent1 == [0, 2] assert sent1 == [0, 1]
assert sent2 == [1, 3] assert sent2 == [2, 3]
assert sched.pending == [4, 5] assert sched.pending == [4, 5]
assert sched.node2pending[node1] == sent1 assert sched.node2pending[node1] == sent1
assert sched.node2pending[node2] == sent2 assert sched.node2pending[node2] == sent2
assert len(sched.pending) == 2 assert len(sched.pending) == 2
sched.mark_test_complete(node1, 0) sched.mark_test_complete(node1, 0)
assert node1.sent == [0, 2, 4] assert node1.sent == [0, 1, 4]
assert sched.pending == [5] assert sched.pending == [5]
assert node2.sent == [1, 3] assert node2.sent == [2, 3]
sched.mark_test_complete(node1, 2) sched.mark_test_complete(node1, 1)
assert node1.sent == [0, 2, 4, 5] assert node1.sent == [0, 1, 4, 5]
assert not sched.pending assert not sched.pending
def test_schedule_fewer_tests_than_nodes(self, pytester: pytest.Pytester) -> None: def test_schedule_maxchunk_none(self, pytester: pytest.Pytester) -> None:
config = pytester.parseconfig("--tx=2*popen") config = pytester.parseconfig("--tx=2*popen")
sched = LoadScheduling(config) sched = LoadScheduling(config)
sched.add_node(MockNode()) sched.add_node(MockNode())
sched.add_node(MockNode()) sched.add_node(MockNode())
node1, node2 = sched.nodes
col = [f"test{i}" for i in range(16)]
sched.add_node_collection(node1, col)
sched.add_node_collection(node2, col)
sched.schedule()
assert node1.sent == [0, 1]
assert node2.sent == [2, 3]
assert sched.pending == list(range(4, 16))
assert sched.node2pending[node1] == node1.sent
assert sched.node2pending[node2] == node2.sent
sched.mark_test_complete(node1, 0)
assert node1.sent == [0, 1, 4, 5]
assert sched.pending == list(range(6, 16))
sched.mark_test_complete(node1, 1)
assert node1.sent == [0, 1, 4, 5]
assert sched.pending == list(range(6, 16))
for i in range(7, 16):
sched.mark_test_complete(node1, i - 3)
assert node1.sent == [0, 1] + list(range(4, i))
assert node2.sent == [2, 3]
assert sched.pending == list(range(i, 16))
def test_schedule_maxchunk_1(self, pytester: pytest.Pytester) -> None:
config = pytester.parseconfig("--tx=2*popen", "--maxschedchunk=1")
sched = LoadScheduling(config)
sched.add_node(MockNode())
sched.add_node(MockNode())
node1, node2 = sched.nodes
col = [f"test{i}" for i in range(16)]
sched.add_node_collection(node1, col)
sched.add_node_collection(node2, col)
sched.schedule()
assert node1.sent == [0, 1]
assert node2.sent == [2, 3]
assert sched.pending == list(range(4, 16))
assert sched.node2pending[node1] == node1.sent
assert sched.node2pending[node2] == node2.sent
for complete_index, first_pending in enumerate(range(5, 16)):
sched.mark_test_complete(node1, node1.sent[complete_index])
assert node1.sent == [0, 1] + list(range(4, first_pending))
assert node2.sent == [2, 3]
assert sched.pending == list(range(first_pending, 16))
def test_schedule_fewer_tests_than_nodes(self, pytester: pytest.Pytester) -> None:
config = pytester.parseconfig("--tx=3*popen")
sched = LoadScheduling(config)
sched.add_node(MockNode())
sched.add_node(MockNode())
sched.add_node(MockNode()) sched.add_node(MockNode())
node1, node2, node3 = sched.nodes node1, node2, node3 = sched.nodes
col = ["xyz"] * 2 col = ["xyz"] * 2
sched.add_node_collection(node1, col) sched.add_node_collection(node1, col)
sched.add_node_collection(node2, col) sched.add_node_collection(node2, col)
sched.add_node_collection(node3, col)
assert sched.collection_is_completed
sched.schedule() sched.schedule()
# assert not sched.tests_finished # assert not sched.tests_finished
sent1 = node1.sent assert node1.sent == [0]
sent2 = node2.sent assert node2.sent == [1]
sent3 = node3.sent assert node3.sent == []
assert sent1 == [0]
assert sent2 == [1]
assert sent3 == []
assert not sched.pending assert not sched.pending
def test_schedule_fewer_than_two_tests_per_node( def test_schedule_fewer_than_two_tests_per_node(
self, pytester: pytest.Pytester self, pytester: pytest.Pytester
) -> None: ) -> None:
config = pytester.parseconfig("--tx=2*popen") config = pytester.parseconfig("--tx=3*popen")
sched = LoadScheduling(config) sched = LoadScheduling(config)
sched.add_node(MockNode()) sched.add_node(MockNode())
sched.add_node(MockNode()) sched.add_node(MockNode())
@@ -161,14 +220,13 @@ class TestLoadScheduling:
col = ["xyz"] * 5 col = ["xyz"] * 5
sched.add_node_collection(node1, col) sched.add_node_collection(node1, col)
sched.add_node_collection(node2, col) sched.add_node_collection(node2, col)
sched.add_node_collection(node3, col)
assert sched.collection_is_completed
sched.schedule() sched.schedule()
# assert not sched.tests_finished # assert not sched.tests_finished
sent1 = node1.sent assert node1.sent == [0, 3]
sent2 = node2.sent assert node2.sent == [1, 4]
sent3 = node3.sent assert node3.sent == [2]
assert sent1 == [0, 3]
assert sent2 == [1, 4]
assert sent3 == [2]
assert not sched.pending assert not sched.pending
def test_add_remove_node(self, pytester: pytest.Pytester) -> None: def test_add_remove_node(self, pytester: pytest.Pytester) -> None:
@@ -217,6 +275,169 @@ class TestLoadScheduling:
assert "Different tests were collected between" in rep.longrepr assert "Different tests were collected between" in rep.longrepr
class TestWorkStealingScheduling:
def test_ideal_case(self, pytester: pytest.Pytester) -> None:
config = pytester.parseconfig("--tx=2*popen")
sched = WorkStealingScheduling(config)
sched.add_node(MockNode())
sched.add_node(MockNode())
node1, node2 = sched.nodes
collection = [f"test_workstealing.py::test_{i}" for i in range(16)]
assert not sched.collection_is_completed
sched.add_node_collection(node1, collection)
assert not sched.collection_is_completed
sched.add_node_collection(node2, collection)
assert sched.collection_is_completed
assert sched.node2collection[node1] == collection
assert sched.node2collection[node2] == collection
sched.schedule()
assert not sched.pending
assert not sched.tests_finished
assert node1.sent == list(range(0, 8))
assert node2.sent == list(range(8, 16))
for i in range(8):
sched.mark_test_complete(node1, node1.sent[i])
sched.mark_test_complete(node2, node2.sent[i])
assert sched.tests_finished
assert node1.stolen == []
assert node2.stolen == []
def test_stealing(self, pytester: pytest.Pytester) -> None:
config = pytester.parseconfig("--tx=2*popen")
sched = WorkStealingScheduling(config)
sched.add_node(MockNode())
sched.add_node(MockNode())
node1, node2 = sched.nodes
collection = [f"test_workstealing.py::test_{i}" for i in range(16)]
sched.add_node_collection(node1, collection)
sched.add_node_collection(node2, collection)
assert sched.collection_is_completed
sched.schedule()
assert node1.sent == list(range(0, 8))
assert node2.sent == list(range(8, 16))
for i in range(8):
sched.mark_test_complete(node1, node1.sent[i])
assert node2.stolen == list(range(12, 16))
sched.remove_pending_tests_from_node(node2, node2.stolen)
for i in range(4):
sched.mark_test_complete(node2, node2.sent[i])
assert node1.stolen == [14, 15]
sched.remove_pending_tests_from_node(node1, node1.stolen)
sched.mark_test_complete(node1, 12)
sched.mark_test_complete(node2, 14)
assert node2.stolen == list(range(12, 16))
assert node1.stolen == [14, 15]
assert sched.tests_finished
def test_steal_on_add_node(self, pytester: pytest.Pytester) -> None:
node = MockNode()
config = pytester.parseconfig("--tx=popen")
sched = WorkStealingScheduling(config)
sched.add_node(node)
collection = [f"test_workstealing.py::test_{i}" for i in range(5)]
sched.add_node_collection(node, collection)
assert sched.collection_is_completed
sched.schedule()
assert not sched.pending
sched.mark_test_complete(node, 0)
node2 = MockNode()
sched.add_node(node2)
sched.add_node_collection(node2, collection)
assert sched.collection_is_completed
sched.schedule()
assert node.stolen == [3, 4]
sched.remove_pending_tests_from_node(node, node.stolen)
sched.mark_test_complete(node, 1)
sched.mark_test_complete(node2, 3)
assert sched.tests_finished
assert node2.stolen == []
def test_schedule_fewer_tests_than_nodes(self, pytester: pytest.Pytester) -> None:
config = pytester.parseconfig("--tx=3*popen")
sched = WorkStealingScheduling(config)
sched.add_node(MockNode())
sched.add_node(MockNode())
sched.add_node(MockNode())
node1, node2, node3 = sched.nodes
col = ["xyz"] * 2
sched.add_node_collection(node1, col)
sched.add_node_collection(node2, col)
sched.add_node_collection(node3, col)
sched.schedule()
assert node1.sent == []
assert node1.stolen == []
assert node2.sent == [0]
assert node2.stolen == []
assert node3.sent == [1]
assert node3.stolen == []
assert not sched.pending
assert sched.tests_finished
def test_schedule_fewer_than_two_tests_per_node(
self, pytester: pytest.Pytester
) -> None:
config = pytester.parseconfig("--tx=3*popen")
sched = WorkStealingScheduling(config)
sched.add_node(MockNode())
sched.add_node(MockNode())
sched.add_node(MockNode())
node1, node2, node3 = sched.nodes
col = ["xyz"] * 5
sched.add_node_collection(node1, col)
sched.add_node_collection(node2, col)
sched.add_node_collection(node3, col)
sched.schedule()
assert node1.sent == [0]
assert node2.sent == [1, 2]
assert node3.sent == [3, 4]
assert not sched.pending
assert not sched.tests_finished
sched.mark_test_complete(node1, node1.sent[0])
sched.mark_test_complete(node2, node2.sent[0])
sched.mark_test_complete(node3, node3.sent[0])
sched.mark_test_complete(node3, node3.sent[1])
assert sched.tests_finished
assert node1.stolen == []
assert node2.stolen == []
assert node3.stolen == []
def test_add_remove_node(self, pytester: pytest.Pytester) -> None:
node = MockNode()
config = pytester.parseconfig("--tx=popen")
sched = WorkStealingScheduling(config)
sched.add_node(node)
collection = ["test_file.py::test_func"]
sched.add_node_collection(node, collection)
assert sched.collection_is_completed
sched.schedule()
assert not sched.pending
crashitem = sched.remove_node(node)
assert crashitem == collection[0]
def test_different_tests_collected(self, pytester: pytest.Pytester) -> None:
class CollectHook:
def __init__(self):
self.reports = []
def pytest_collectreport(self, report):
self.reports.append(report)
collect_hook = CollectHook()
config = pytester.parseconfig("--tx=2*popen")
config.pluginmanager.register(collect_hook, "collect_hook")
node1 = MockNode()
node2 = MockNode()
sched = WorkStealingScheduling(config)
sched.add_node(node1)
sched.add_node(node2)
sched.add_node_collection(node1, ["a.py::test_1"])
sched.add_node_collection(node2, ["a.py::test_2"])
sched.schedule()
assert len(collect_hook.reports) == 1
rep = collect_hook.reports[0]
assert "Different tests were collected between" in rep.longrepr
class TestDistReporter: class TestDistReporter:
@pytest.mark.xfail @pytest.mark.xfail
def test_rsync_printing(self, pytester: pytest.Pytester, linecomp) -> None: def test_rsync_printing(self, pytester: pytest.Pytester, linecomp) -> None:
@@ -258,7 +479,7 @@ def test_report_collection_diff_equal() -> None:
def test_default_max_worker_restart() -> None: def test_default_max_worker_restart() -> None:
class config: class config:
class option: class option:
maxworkerrestart: Optional[str] = None maxworkerrestart: str | None = None
numprocesses: int = 0 numprocesses: int = 0
assert get_default_max_worker_restart(config) is None assert get_default_max_worker_restart(config) is None
@@ -290,7 +511,8 @@ def test_report_collection_diff_different() -> None:
" bbb\n" " bbb\n"
"+XXX\n" "+XXX\n"
" ccc\n" " ccc\n"
"-YYY" "-YYY\n"
"To see why this happens see Known limitations in documentation"
) )
msg = report_collection_diff(from_collection, to_collection, "1", "2") msg = report_collection_diff(from_collection, to_collection, "1", "2")
@@ -311,3 +533,70 @@ def test_pytest_issue419(pytester: pytest.Pytester) -> None:
reprec = pytester.inline_run("-n1") reprec = pytester.inline_run("-n1")
reprec.assertoutcome(passed=2) reprec.assertoutcome(passed=2)
assert 0 assert 0
Created = WorkerStatus.Created
Initialized = WorkerStatus.Initialized
ReadyForCollection = WorkerStatus.ReadyForCollection
CollectionDone = WorkerStatus.CollectionDone
@pytest.mark.parametrize(
"status_and_items, expected",
[
(
[],
"",
),
(
[(Created, 0)],
"created: 1/1 worker",
),
(
[(Created, 0), (Created, 0)],
"created: 2/2 workers",
),
(
[(Initialized, 0), (Created, 0)],
"initialized: 1/2 workers",
),
(
[(Initialized, 0), (Initialized, 0)],
"initialized: 2/2 workers",
),
(
[(ReadyForCollection, 0), (Created, 0)],
"ready: 1/2 workers",
),
(
[(ReadyForCollection, 0), (ReadyForCollection, 0)],
"ready: 2/2 workers",
),
(
[(CollectionDone, 12), (Created, 0)],
"collecting: 1/2 workers",
),
(
[(CollectionDone, 12), (CollectionDone, 12)],
"2 workers [12 items]",
),
(
[(CollectionDone, 1), (CollectionDone, 1)],
"2 workers [1 item]",
),
(
[(CollectionDone, 1)],
"1 worker [1 item]",
),
# Different number of tests collected will raise an error and should not happen in practice,
# but we test for it anyway.
(
[(CollectionDone, 1), (CollectionDone, 12)],
"2 workers [1 item]",
),
],
)
def test_get_workers_status_line(
status_and_items: Sequence[tuple[WorkerStatus, int]], expected: str
) -> None:
assert get_workers_status_line(status_and_items) == expected

View File

@@ -1,4 +1,6 @@
import py import unittest.mock
from typing import List
import pytest import pytest
import shutil import shutil
import textwrap import textwrap
@@ -16,7 +18,7 @@ class TestStatRecorder:
tmp = tmp_path tmp = tmp_path
hello = tmp / "hello.py" hello = tmp / "hello.py"
hello.touch() hello.touch()
sd = StatRecorder([py.path.local(tmp)]) sd = StatRecorder([tmp])
changed = sd.check() changed = sd.check()
assert not changed assert not changed
@@ -56,15 +58,12 @@ class TestStatRecorder:
tmp = tmp_path tmp = tmp_path
tmp.joinpath("dir").mkdir() tmp.joinpath("dir").mkdir()
tmp.joinpath("dir", "hello.py").touch() tmp.joinpath("dir", "hello.py").touch()
sd = StatRecorder([py.path.local(tmp)]) sd = StatRecorder([tmp])
assert not sd.fil(py.path.local(tmp / "dir")) assert not sd.fil(tmp / "dir")
def test_filechange_deletion_race( def test_filechange_deletion_race(self, tmp_path: Path) -> None:
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
tmp = tmp_path tmp = tmp_path
pytmp = py.path.local(tmp) sd = StatRecorder([tmp])
sd = StatRecorder([pytmp])
changed = sd.check() changed = sd.check()
assert not changed assert not changed
@@ -76,16 +75,20 @@ class TestStatRecorder:
p.unlink() p.unlink()
# make check()'s visit() call return our just removed # make check()'s visit() call return our just removed
# path as if we were in a race condition # path as if we were in a race condition
monkeypatch.setattr(pytmp, "visit", lambda *args: [py.path.local(p)]) dirname = str(tmp)
dirnames: List[str] = []
changed = sd.check() filenames = [str(p)]
with unittest.mock.patch(
"os.walk", return_value=[(dirname, dirnames, filenames)], autospec=True
):
changed = sd.check()
assert changed assert changed
def test_pycremoval(self, tmp_path: Path) -> None: def test_pycremoval(self, tmp_path: Path) -> None:
tmp = tmp_path tmp = tmp_path
hello = tmp / "hello.py" hello = tmp / "hello.py"
hello.touch() hello.touch()
sd = StatRecorder([py.path.local(tmp)]) sd = StatRecorder([tmp])
changed = sd.check() changed = sd.check()
assert not changed assert not changed
@@ -100,7 +103,7 @@ class TestStatRecorder:
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
tmp = tmp_path tmp = tmp_path
sd = StatRecorder([py.path.local(tmp)]) sd = StatRecorder([tmp])
ret_values = [True, False] ret_values = [True, False]
monkeypatch.setattr(StatRecorder, "check", lambda self: ret_values.pop()) monkeypatch.setattr(StatRecorder, "check", lambda self: ret_values.pop())
@@ -123,7 +126,7 @@ class TestRemoteControl:
failures = control.runsession() failures = control.runsession()
assert failures assert failures
control.setup() control.setup()
item_path = item.path if PYTEST_GTE_7 else Path(item.fspath) # type: ignore[attr-defined] item_path = item.path if PYTEST_GTE_7 else Path(str(item.fspath)) # type: ignore[attr-defined]
item_path.write_text("def test_func():\n assert 1\n") item_path.write_text("def test_func():\n assert 1\n")
removepyc(item_path) removepyc(item_path)
topdir, failures = control.runsession()[:2] topdir, failures = control.runsession()[:2]
@@ -141,7 +144,11 @@ class TestRemoteControl:
control = RemoteControl(modcol.config) control = RemoteControl(modcol.config)
control.loop_once() control.loop_once()
assert control.failures assert control.failures
modcol_path = modcol.path if PYTEST_GTE_7 else Path(modcol.fspath) # type: ignore[attr-defined] if PYTEST_GTE_7:
modcol_path = modcol.path # type:ignore[attr-defined]
else:
modcol_path = Path(str(modcol.fspath))
modcol_path.write_text( modcol_path.write_text(
textwrap.dedent( textwrap.dedent(
""" """

View File

@@ -94,3 +94,28 @@ class TestCrashItem:
res = pytester.runpytest("-n2", "-s") res = pytester.runpytest("-n2", "-s")
res.stdout.fnmatch_lines_random(["*HOOK: pytest_handlecrashitem"]) res.stdout.fnmatch_lines_random(["*HOOK: pytest_handlecrashitem"])
res.stdout.fnmatch_lines(["*3 passed*"]) res.stdout.fnmatch_lines(["*3 passed*"])
def test_handlecrashitem_one(self, pytester: pytest.Pytester) -> None:
"""Test pytest_handlecrashitem hook with just one test."""
pytester.makeconftest(
"""
test_runs = 0
def pytest_handlecrashitem(crashitem, report, sched):
global test_runs
if test_runs == 0:
sched.mark_test_pending(crashitem)
test_runs = 1
else:
print("HOOK: pytest_handlecrashitem")
"""
)
res = pytester.runpytest("-n1", "-s", "-k", "test_b")
res.stdout.fnmatch_lines_random(["*HOOK: pytest_handlecrashitem"])
res.stdout.fnmatch_lines(
[
"FAILED test_handlecrashitem_one.py::test_b",
"FAILED test_handlecrashitem_one.py::test_b",
]
)

View File

@@ -1,5 +1,7 @@
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
import sys
import os
import execnet import execnet
from xdist.workermanage import NodeManager from xdist.workermanage import NodeManager
@@ -7,6 +9,15 @@ from xdist.workermanage import NodeManager
import pytest import pytest
@pytest.fixture
def monkeypatch_3_cpus(monkeypatch: pytest.MonkeyPatch):
"""Make pytest-xdist believe the system has 3 CPUs"""
# block import
monkeypatch.setitem(sys.modules, "psutil", None) # type: ignore
monkeypatch.delattr(os, "sched_getaffinity", raising=False)
monkeypatch.setattr(os, "cpu_count", lambda: 3)
def test_dist_incompatibility_messages(pytester: pytest.Pytester) -> None: def test_dist_incompatibility_messages(pytester: pytest.Pytester) -> None:
result = pytester.runpytest("--pdb", "--looponfail") result = pytester.runpytest("--pdb", "--looponfail")
assert result.ret != 0 assert result.ret != 0
@@ -41,9 +52,10 @@ def test_dist_options(pytester: pytest.Pytester) -> None:
def test_auto_detect_cpus( def test_auto_detect_cpus(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
import os
from xdist.plugin import pytest_cmdline_main as check_options from xdist.plugin import pytest_cmdline_main as check_options
monkeypatch.delenv("PYTEST_XDIST_AUTO_NUM_WORKERS", raising=False)
with suppress(ImportError): with suppress(ImportError):
import psutil import psutil
@@ -91,6 +103,7 @@ def test_auto_detect_cpus_psutil(
psutil = pytest.importorskip("psutil") psutil = pytest.importorskip("psutil")
monkeypatch.delenv("PYTEST_XDIST_AUTO_NUM_WORKERS", raising=False)
monkeypatch.setattr(psutil, "cpu_count", lambda logical=True: 84 if logical else 42) monkeypatch.setattr(psutil, "cpu_count", lambda logical=True: 84 if logical else 42)
config = pytester.parseconfigure("-nauto") config = pytester.parseconfigure("-nauto")
@@ -102,6 +115,22 @@ def test_auto_detect_cpus_psutil(
assert config.getoption("numprocesses") == 84 assert config.getoption("numprocesses") == 84
def test_auto_detect_cpus_os(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, monkeypatch_3_cpus
) -> None:
from xdist.plugin import pytest_cmdline_main as check_options
monkeypatch.delenv("PYTEST_XDIST_AUTO_NUM_WORKERS", raising=False)
config = pytester.parseconfigure("-nauto")
check_options(config)
assert config.getoption("numprocesses") == 3
config = pytester.parseconfigure("-nlogical")
check_options(config)
assert config.getoption("numprocesses") == 3
def test_hook_auto_num_workers( def test_hook_auto_num_workers(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
@@ -122,20 +151,105 @@ def test_hook_auto_num_workers(
assert config.getoption("numprocesses") == 42 assert config.getoption("numprocesses") == 42
def test_boxed_with_collect_only(pytester: pytest.Pytester) -> None: def test_hook_auto_num_workers_arg(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
) -> None:
# config.option.numprocesses is a pytest feature,
# but we document it so let's test it.
from xdist.plugin import pytest_cmdline_main as check_options from xdist.plugin import pytest_cmdline_main as check_options
config = pytester.parseconfigure("-n1", "--boxed") pytester.makeconftest(
"""
def pytest_xdist_auto_num_workers(config):
if config.option.numprocesses == 'auto':
return 42
if config.option.numprocesses == 'logical':
return 8
"""
)
config = pytester.parseconfigure("-nauto")
check_options(config) check_options(config)
assert config.option.forked assert config.getoption("numprocesses") == 42
config = pytester.parseconfigure("-n1", "--collect-only") config = pytester.parseconfigure("-nlogical")
check_options(config) check_options(config)
assert not config.option.forked assert config.getoption("numprocesses") == 8
config = pytester.parseconfigure("-n1", "--boxed", "--collect-only")
def test_hook_auto_num_workers_none(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, monkeypatch_3_cpus
) -> None:
# Returning None from a hook to skip it is pytest behavior,
# but we document it so let's test it.
from xdist.plugin import pytest_cmdline_main as check_options
monkeypatch.delenv("PYTEST_XDIST_AUTO_NUM_WORKERS", raising=False)
pytester.makeconftest(
"""
def pytest_xdist_auto_num_workers():
return None
"""
)
config = pytester.parseconfigure("-nauto")
check_options(config) check_options(config)
assert config.option.forked assert config.getoption("numprocesses") == 3
monkeypatch.setenv("PYTEST_XDIST_AUTO_NUM_WORKERS", "5")
config = pytester.parseconfigure("-nauto")
check_options(config)
assert config.getoption("numprocesses") == 5
def test_envvar_auto_num_workers(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
) -> None:
from xdist.plugin import pytest_cmdline_main as check_options
monkeypatch.setenv("PYTEST_XDIST_AUTO_NUM_WORKERS", "7")
config = pytester.parseconfigure("-nauto")
check_options(config)
assert config.getoption("numprocesses") == 7
config = pytester.parseconfigure("-nlogical")
check_options(config)
assert config.getoption("numprocesses") == 7
def test_envvar_auto_num_workers_warn(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, monkeypatch_3_cpus
) -> None:
from xdist.plugin import pytest_cmdline_main as check_options
monkeypatch.setenv("PYTEST_XDIST_AUTO_NUM_WORKERS", "fourscore")
config = pytester.parseconfigure("-nauto")
with pytest.warns(UserWarning):
check_options(config)
assert config.getoption("numprocesses") == 3
def test_auto_num_workers_hook_overrides_envvar(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, monkeypatch_3_cpus
) -> None:
from xdist.plugin import pytest_cmdline_main as check_options
monkeypatch.setenv("PYTEST_XDIST_AUTO_NUM_WORKERS", "987")
pytester.makeconftest(
"""
def pytest_xdist_auto_num_workers():
return 2
"""
)
config = pytester.parseconfigure("-nauto")
check_options(config)
assert config.getoption("numprocesses") == 2
config = pytester.parseconfigure("-nauto")
check_options(config)
assert config.getoption("numprocesses") == 2
def test_dsession_with_collect_only(pytester: pytest.Pytester) -> None: def test_dsession_with_collect_only(pytester: pytest.Pytester) -> None:

View File

@@ -1,5 +1,4 @@
import pprint import pprint
import py
import pytest import pytest
import sys import sys
import uuid import uuid
@@ -26,15 +25,14 @@ class EventCall:
self.name, self.kwargs = eventcall self.name, self.kwargs = eventcall
def __str__(self): def __str__(self):
return "<EventCall {}(**{})>".format(self.name, self.kwargs) return f"<EventCall {self.name}(**{self.kwargs})>"
class WorkerSetup: class WorkerSetup:
use_callback = False
def __init__(self, request, pytester: pytest.Pytester) -> None: def __init__(self, request, pytester: pytest.Pytester) -> None:
self.request = request self.request = request
self.pytester = pytester self.pytester = pytester
self.use_callback = False
self.events = Queue() # type: ignore[var-annotated] self.events = Queue() # type: ignore[var-annotated]
def setup(self) -> None: def setup(self) -> None:
@@ -42,7 +40,7 @@ class WorkerSetup:
# import os ; os.environ['EXECNET_DEBUG'] = "2" # import os ; os.environ['EXECNET_DEBUG'] = "2"
self.gateway = execnet.makegateway() self.gateway = execnet.makegateway()
self.config = config = self.pytester.parseconfigure() self.config = config = self.pytester.parseconfigure()
putevent = self.use_callback and self.events.put or None putevent = self.events.put if self.use_callback else None
class DummyMananger: class DummyMananger:
testrunuid = uuid.uuid4().hex testrunuid = uuid.uuid4().hex
@@ -61,7 +59,7 @@ class WorkerSetup:
ev = EventCall(data) ev = EventCall(data)
if name is None or ev.name == name: if name is None or ev.name == name:
return ev return ev
print("skipping {}".format(ev)) print(f"skipping {ev}")
def sendcommand(self, name, **kwargs): def sendcommand(self, name, **kwargs):
self.slp.sendcommand(name, **kwargs) self.slp.sendcommand(name, **kwargs)
@@ -108,7 +106,7 @@ class TestWorkerInteractor:
assert ev.name == "collectionstart" assert ev.name == "collectionstart"
assert not ev.kwargs assert not ev.kwargs
ev = worker.popevent("collectionfinish") ev = worker.popevent("collectionfinish")
assert ev.kwargs["topdir"] == py.path.local(worker.pytester.path) assert ev.kwargs["topdir"] == str(worker.pytester.path)
ids = ev.kwargs["ids"] ids = ev.kwargs["ids"]
assert len(ids) == 1 assert len(ids) == 1
worker.sendcommand("runtests", indices=list(range(len(ids)))) worker.sendcommand("runtests", indices=list(range(len(ids))))
@@ -222,6 +220,91 @@ class TestWorkerInteractor:
ev = worker.popevent() ev = worker.popevent()
assert ev.name == "errordown" assert ev.name == "errordown"
def test_steal_work(self, worker: WorkerSetup, unserialize_report) -> None:
worker.pytester.makepyfile(
"""
import time
def test_func(): time.sleep(1)
def test_func2(): pass
def test_func3(): pass
def test_func4(): pass
"""
)
worker.setup()
ev = worker.popevent("collectionfinish")
ids = ev.kwargs["ids"]
assert len(ids) == 4
worker.sendcommand("runtests_all")
# wait for test_func setup
ev = worker.popevent("testreport")
rep = unserialize_report(ev.kwargs["data"])
assert rep.nodeid.endswith("::test_func")
assert rep.when == "setup"
worker.sendcommand("steal", indices=[1, 2])
ev = worker.popevent("unscheduled")
assert ev.kwargs["indices"] == [2]
reports = [
("test_func", "call"),
("test_func", "teardown"),
("test_func2", "setup"),
("test_func2", "call"),
("test_func2", "teardown"),
]
for func, when in reports:
ev = worker.popevent("testreport")
rep = unserialize_report(ev.kwargs["data"])
assert rep.nodeid.endswith(f"::{func}")
assert rep.when == when
worker.sendcommand("shutdown")
for when in ["setup", "call", "teardown"]:
ev = worker.popevent("testreport")
rep = unserialize_report(ev.kwargs["data"])
assert rep.nodeid.endswith("::test_func4")
assert rep.when == when
ev = worker.popevent("workerfinished")
assert "workeroutput" in ev.kwargs
def test_steal_empty_queue(self, worker: WorkerSetup, unserialize_report) -> None:
worker.pytester.makepyfile(
"""
def test_func(): pass
def test_func2(): pass
"""
)
worker.setup()
ev = worker.popevent("collectionfinish")
ids = ev.kwargs["ids"]
assert len(ids) == 2
worker.sendcommand("runtests_all")
for when in ["setup", "call", "teardown"]:
ev = worker.popevent("testreport")
rep = unserialize_report(ev.kwargs["data"])
assert rep.nodeid.endswith("::test_func")
assert rep.when == when
worker.sendcommand("steal", indices=[0, 1])
ev = worker.popevent("unscheduled")
assert ev.kwargs["indices"] == []
worker.sendcommand("shutdown")
for when in ["setup", "call", "teardown"]:
ev = worker.popevent("testreport")
rep = unserialize_report(ev.kwargs["data"])
assert rep.nodeid.endswith("::test_func2")
assert rep.when == when
ev = worker.popevent("workerfinished")
assert "workeroutput" in ev.kwargs
def test_remote_env_vars(pytester: pytest.Pytester) -> None: def test_remote_env_vars(pytester: pytest.Pytester) -> None:
pytester.makepyfile( pytester.makepyfile(

View File

@@ -1,11 +1,14 @@
import execnet import execnet
import py
import pytest import pytest
import shutil import shutil
import textwrap import textwrap
import warnings
from pathlib import Path from pathlib import Path
from util import generate_warning
from xdist import workermanage from xdist import workermanage
from xdist.workermanage import HostRSync, NodeManager from xdist._path import visit_path
from xdist.remote import serialize_warning_message
from xdist.workermanage import HostRSync, NodeManager, unserialize_warning_message
pytest_plugins = "pytester" pytest_plugins = "pytester"
@@ -154,12 +157,9 @@ class TestHRSync:
source.joinpath("somedir").mkdir() source.joinpath("somedir").mkdir()
source.joinpath("somedir", "editfile~").touch() source.joinpath("somedir", "editfile~").touch()
syncer = HostRSync(source, ignores=NodeManager.DEFAULT_IGNORES) syncer = HostRSync(source, ignores=NodeManager.DEFAULT_IGNORES)
files = list(py.path.local(source).visit(rec=syncer.filter, fil=syncer.filter)) files = list(visit_path(source, recurse=syncer.filter, filter=syncer.filter))
assert len(files) == 3 names = {x.name for x in files}
basenames = [x.basename for x in files] assert names == {"dir", "file.txt", "somedir"}
assert "dir" in basenames
assert "file.txt" in basenames
assert "somedir" in basenames
def test_hrsync_one_host(self, source: Path, dest: Path) -> None: def test_hrsync_one_host(self, source: Path, dest: Path) -> None:
gw = execnet.makegateway("popen//chdir=%s" % dest) gw = execnet.makegateway("popen//chdir=%s" % dest)
@@ -326,3 +326,80 @@ class TestNodeManager:
) )
(rep,) = reprec.getreports("pytest_runtest_logreport") (rep,) = reprec.getreports("pytest_runtest_logreport")
assert rep.passed assert rep.passed
class MyWarning(UserWarning):
pass
@pytest.mark.parametrize(
"w_cls",
[
UserWarning,
MyWarning,
"Imported",
pytest.param(
"Nested",
marks=pytest.mark.xfail(reason="Nested warning classes are not supported."),
),
],
)
def test_unserialize_warning_msg(w_cls):
"""Test that warning serialization process works well"""
# Create a test warning message
with pytest.warns(UserWarning) as w:
if not isinstance(w_cls, str):
warnings.warn("hello", w_cls)
elif w_cls == "Imported":
generate_warning()
elif w_cls == "Nested":
# dynamic creation
class MyWarning2(UserWarning):
pass
warnings.warn("hello", MyWarning2)
# Unpack
assert len(w) == 1
w_msg = w[0]
# Serialize and deserialize
data = serialize_warning_message(w_msg)
w_msg2 = unserialize_warning_message(data)
# Compare the two objects
all_keys = set(vars(w_msg).keys()).union(set(vars(w_msg2).keys()))
for k in all_keys:
v1 = getattr(w_msg, k)
v2 = getattr(w_msg2, k)
if k == "message":
assert type(v1) == type(v2)
assert v1.args == v2.args
else:
assert v1 == v2
class MyWarningUnknown(UserWarning):
# Changing the __module__ attribute is only safe if class can be imported
# from there
__module__ = "unknown"
def test_warning_serialization_tweaked_module():
"""Test for GH#404"""
# Create a test warning message
with pytest.warns(UserWarning) as w:
warnings.warn("hello", MyWarningUnknown)
# Unpack
assert len(w) == 1
w_msg = w[0]
# Serialize and deserialize
data = serialize_warning_message(w_msg)
# __module__ cannot be found!
with pytest.raises(ModuleNotFoundError):
unserialize_warning_message(data)

9
testing/util.py Normal file
View File

@@ -0,0 +1,9 @@
import warnings
class MyWarning2(UserWarning):
pass
def generate_warning():
warnings.warn(MyWarning2("hello"))

29
tox.ini
View File

@@ -1,11 +1,11 @@
[tox] [tox]
envlist= envlist=
linting linting
py{36,37,38,39,310}-pytestlatest py{37,38,39,310,311}-pytestlatest
py38-pytestmain py310-pytestmain
py38-psutil py310-psutil
py38-setproctitle py310-setproctitle
isolated_build = true
[testenv] [testenv]
extras = testing extras = testing
deps = deps =
@@ -14,14 +14,14 @@ deps =
commands= commands=
pytest {posargs} pytest {posargs}
[testenv:py38-psutil] [testenv:py310-psutil]
extras = extras =
testing testing
psutil psutil
commands = commands =
pytest {posargs:-k psutil} pytest {posargs:-k psutil}
[testenv:py38-setproctitle] [testenv:py310-setproctitle]
extras = extras =
testing testing
setproctitle setproctitle
@@ -39,15 +39,24 @@ commands = pre-commit run --all-files --show-diff-on-failure
[testenv:release] [testenv:release]
changedir= changedir=
decription = do a release, required posarg of the version number description = do a release, required posarg of the version number
basepython = python3.7 basepython = python3.10
skipsdist = True skipsdist = True
usedevelop = True usedevelop = True
passenv = * passenv = *
deps = deps =
towncrier towncrier
commands = commands =
towncrier --version {posargs} --yes towncrier build --version {posargs} --yes
[testenv:docs]
basepython = python3.10
usedevelop = True
deps =
sphinx
sphinx_rtd_theme
commands =
sphinx-build -W --keep-going -b html docs docs/_build/html {posargs:}
[pytest] [pytest]
# pytest-services also defines a worker_id fixture, disable # pytest-services also defines a worker_id fixture, disable