Compare commits

...

309 Commits

Author SHA1 Message Date
23461ed23b Preserve mirrored pytest-xdist version 2026-09-02 15:20:28 -05:00
Shane Smiskol
2b4372bd62 gotcha
Some checks failed
test / package (push) Failing after 2m31s
test / test (ubuntu-latest, 3.10, py310-psutil) (push) Has been skipped
test / test (ubuntu-latest, 3.10, py310-pytestlatest) (push) Has been skipped
test / test (ubuntu-latest, 3.10, py310-setproctitle) (push) Has been skipped
test / test (ubuntu-latest, 3.11, py311-pytestlatest) (push) Has been skipped
test / test (ubuntu-latest, 3.11, py311-pytestmain) (push) Has been skipped
test / test (ubuntu-latest, 3.12, py312-pytestlatest) (push) Has been skipped
test / test (ubuntu-latest, 3.13, py313-pytestlatest) (push) Has been skipped
test / test (ubuntu-latest, 3.9, py39-pytestlatest) (push) Has been skipped
test / test (ubuntu-latest, 3.9, py39-pytestmin) (push) Has been skipped
test / test (windows-latest, 3.10, py310-psutil) (push) Has been skipped
test / test (windows-latest, 3.10, py310-pytestlatest) (push) Has been skipped
test / test (windows-latest, 3.10, py310-setproctitle) (push) Has been skipped
test / test (windows-latest, 3.11, py311-pytestlatest) (push) Has been skipped
test / test (windows-latest, 3.11, py311-pytestmain) (push) Has been skipped
test / test (windows-latest, 3.12, py312-pytestlatest) (push) Has been skipped
test / test (windows-latest, 3.13, py313-pytestlatest) (push) Has been skipped
test / test (windows-latest, 3.9, py39-pytestlatest) (push) Has been skipped
test / test (windows-latest, 3.9, py39-pytestmin) (push) Has been skipped
2025-07-25 19:39:12 -07:00
Shane Smiskol
1bb1a0f62a one line 2025-07-25 19:38:49 -07:00
Shane Smiskol
f8ea4a23b7 fix again 2025-07-25 19:37:34 -07:00
pre-commit-ci[bot]
68bf76dce4 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-07-26 02:35:24 +00:00
Shane Smiskol
28c0a72e3a fix test 2025-07-25 19:35:11 -07:00
pre-commit-ci[bot]
9d7dcae9d2 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-07-26 02:14:35 +00:00
Shane Smiskol
a3374b8d5c clean up 2025-07-25 19:14:22 -07:00
Shane Smiskol
5824f221ca clean up 2025-07-25 19:14:10 -07:00
pre-commit-ci[bot]
6ca0790e02 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-07-26 02:12:40 +00:00
Shane Smiskol
b59e010821 parallel 2025-07-25 18:50:32 -07:00
pre-commit-ci[bot]
239def9be9 Merge pull request #1227 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2025-07-14 19:34:51 -03:00
pre-commit-ci[bot]
ec87d0a139 [pre-commit.ci] pre-commit autoupdate (#1226)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.12.1 → v0.12.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.1...v0.12.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-07-08 08:43:31 +02:00
Bruno Oliveira
fe10f1c411 Merge pull request #1224 from pytest-dev/release-3.8.0
Release 3.8.0
2025-07-01 10:31:26 -03:00
pre-commit-ci[bot]
3572d0b13c [pre-commit.ci] pre-commit autoupdate (#1223)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.12.0 → v0.12.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.0...v0.12.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-06-30 18:33:44 -03:00
Bruno Oliveira
1e3e4dc165 Release 3.8.0 2025-06-30 11:05:48 -03:00
Shane Smiskol
600aad575a Ensure all xdist group names are strings (#1216) 2025-06-30 11:01:27 -03:00
darwintree
9d7ba5b5fb Add --no-loadscope-reorder and --loadscope-reorder options (#1217)
Based on #1098.

Closes #1098

---------

Co-authored-by: Toan Vuong <toan.vuong@hyperscience.com>
2025-06-30 10:59:40 -03:00
Bruno Oliveira
532f07fb18 Merge pull request #1210 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2025-06-24 08:24:11 -03:00
Bruno Oliveira
0883ad085e Fix Path usage in test_rsync_roots_no_roots 2025-06-24 08:21:48 -03:00
pre-commit-ci[bot]
58a51bc140 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.11 → v0.12.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.11...v0.12.0)
- [github.com/pre-commit/mirrors-mypy: v1.15.0 → v1.16.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.15.0...v1.16.1)
2025-06-23 20:16:47 +00:00
Bruno Oliveira
59a2ad0150 Merge pull request #1220 from pytest-dev/dependabot/github_actions/github-actions-33a29a28f8
build(deps): bump hynek/build-and-inspect-python-package from 2.12 to 2.13 in the github-actions group
2025-06-16 17:43:36 -03:00
dependabot[bot]
d42b9c7204 build(deps): bump hynek/build-and-inspect-python-package
Bumps the github-actions group with 1 update: [hynek/build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package).


Updates `hynek/build-and-inspect-python-package` from 2.12 to 2.13
- [Release notes](https://github.com/hynek/build-and-inspect-python-package/releases)
- [Changelog](https://github.com/hynek/build-and-inspect-python-package/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/build-and-inspect-python-package/compare/v2.12...v2.13)

---
updated-dependencies:
- dependency-name: hynek/build-and-inspect-python-package
  dependency-version: '2.13'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-16 19:13:50 +00:00
Bruno Oliveira
ebfcb99072 Merge pull request #1206 from pytest-dev/release-3.7.0
Release 3.7.0
2025-05-26 18:24:03 -03:00
pre-commit-ci[bot]
23b7fd6054 [pre-commit.ci] pre-commit autoupdate (#1207)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.10 → v0.11.11](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.10...v0.11.11)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-05-26 22:52:00 +02:00
Bruno Oliveira
b561916eaf Release 3.7.0 2025-05-26 14:51:07 -03:00
pre-commit-ci[bot]
71bd3ede19 [pre-commit.ci] pre-commit autoupdate (#1204)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.9 → v0.11.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.9...v0.11.10)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-05-19 22:37:54 +02:00
Ygor Pontelo
65de0ab30e Join multiple xdist_group markers (#1201)
Fixes #1200

---------

Co-authored-by: Bruno Oliveira <bruno@soliv.dev>
2025-05-14 21:51:59 -03:00
pre-commit-ci[bot]
199f949716 [pre-commit.ci] pre-commit autoupdate (#1202)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.8 → v0.11.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.8...v0.11.9)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-05-12 22:33:00 +02:00
pre-commit-ci[bot]
b0215da1ed [pre-commit.ci] pre-commit autoupdate (#1199)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.7 → v0.11.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.7...v0.11.8)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-05-05 22:53:19 +02:00
pre-commit-ci[bot]
18e47853ed [pre-commit.ci] pre-commit autoupdate (#1198)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.6 → v0.11.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.6...v0.11.7)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-04-28 22:58:57 +02:00
pre-commit-ci[bot]
731c05d86a [pre-commit.ci] pre-commit autoupdate (#1197)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.5 → v0.11.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.5...v0.11.6)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-04-21 22:34:41 +02:00
pre-commit-ci[bot]
16c5be3203 [pre-commit.ci] pre-commit autoupdate (#1195)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.4 → v0.11.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.4...v0.11.5)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-04-15 04:51:22 +02:00
pre-commit-ci[bot]
e03757a1d7 [pre-commit.ci] pre-commit autoupdate (#1193)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.2 → v0.11.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.2...v0.11.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-04-08 06:16:53 +02:00
Ronny Pfannschmidt
f1d81a4402 Merge pull request #1192 from adamchainz/document_disabling
Document disabling with '-n 0'
2025-04-02 20:07:48 +02:00
Adam Johnson
8074c633c8 Document disabling with '-n 0' 2025-04-02 11:36:41 +01:00
Marc Mueller
f83299cd64 Use SPDX license identifier (#1190)
Co-authored-by: Pierre Sassoulas <pierre.sassoulas@gmail.com>
2025-03-25 13:36:07 +01:00
pre-commit-ci[bot]
4d970380ee [pre-commit.ci] pre-commit autoupdate (#1191)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.0 → v0.11.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.0...v0.11.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-24 21:58:22 +01:00
pre-commit-ci[bot]
10941bad70 [pre-commit.ci] pre-commit autoupdate (#1188)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.10 → v0.11.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.10...v0.11.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-17 22:43:23 +01:00
pre-commit-ci[bot]
57e19eca18 [pre-commit.ci] pre-commit autoupdate (#1187)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.9 → v0.9.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.9...v0.9.10)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-10 22:18:38 +01:00
pre-commit-ci[bot]
3dab30ec8a [pre-commit.ci] pre-commit autoupdate (#1185)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.7 → v0.9.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.7...v0.9.9)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-03 22:37:10 +01:00
pre-commit-ci[bot]
3508f8c8ad [pre-commit.ci] pre-commit autoupdate (#1181)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.6 → v0.9.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.6...v0.9.7)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-02-26 08:04:12 -03:00
pre-commit-ci[bot]
84a854438c [pre-commit.ci] pre-commit autoupdate (#1177)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.4 → v0.9.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.4...v0.9.6)
- [github.com/pre-commit/mirrors-mypy: v1.14.1 → v1.15.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.14.1...v1.15.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-02-11 01:37:37 +01:00
pre-commit-ci[bot]
cb97e7c31a [pre-commit.ci] pre-commit autoupdate (#1175)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.2 → v0.9.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.2...v0.9.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-02-04 08:53:44 +01:00
dependabot[bot]
adfba2bbb8 build(deps): bump the github-actions group with 2 updates (#1174)
Bumps the github-actions group with 2 updates: [hynek/build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package) and [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `hynek/build-and-inspect-python-package` from 2.11 to 2.12
- [Release notes](https://github.com/hynek/build-and-inspect-python-package/releases)
- [Changelog](https://github.com/hynek/build-and-inspect-python-package/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/build-and-inspect-python-package/compare/v2.11...v2.12)

Updates `pypa/gh-action-pypi-publish` from 1.12.3 to 1.12.4
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.12.3...v1.12.4)

---
updated-dependencies:
- dependency-name: hynek/build-and-inspect-python-package
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 20:25:10 -03:00
pre-commit-ci[bot]
ab798d52a5 [pre-commit.ci] pre-commit autoupdate (#1173)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.1 → v0.9.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.1...v0.9.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-01-21 05:03:12 +01:00
Ronny Pfannschmidt
987c2ca896 Merge pull request #1171 from Alon-L/feature/proxy-gateways
Add proxy gateways
2025-01-18 13:14:31 +01:00
Ronny Pfannschmidt
8346acc7fb Merge branch 'master' into feature/proxy-gateways 2025-01-18 10:48:48 +01:00
pre-commit-ci[bot]
59f743487f [pre-commit.ci] pre-commit autoupdate (#1172)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.6 → v0.9.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.6...v0.9.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-01-14 09:57:14 +01:00
pre-commit-ci[bot]
c11ca7a8fe [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-01-12 19:32:01 +00:00
Alon Livne
8632b38203 add tests for proxy gateways
The test `test_proxy_gateway_setup_nodes` makes sure that 2 execnet
gateways are created, but only one node is allocated.

The test `test_proxy_gateway` tries to run a test on a proxy.
2025-01-12 21:30:43 +02:00
pre-commit-ci[bot]
35db0e79c2 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-01-12 18:38:19 +00:00
Alon Livne
333e2d3435 add proxy gateways changelog 2025-01-12 20:37:52 +02:00
Alon Livne
17ac3359e3 add proxy gateway docs
Add docs for using proxy gateways to run multiple workers on remote
machines
2025-01-12 20:36:02 +02:00
Alon Livne
a9a303f688 add proxy gateways with --px arg
Proxy gateways do not run workers, and are meant to be passed with the
`via` attribute to additional gateways.
They are useful for running multiple workers on remote machines.

Example usage:
```
pytest -sv --dist=load --px "id=my_proxy//socket=IP:PORT" --tx "5*popen//via=my_proxy"
```

Proxy gateways do not run workers, anda re meant to be passed
2025-01-12 20:33:31 +02:00
pre-commit-ci[bot]
a82981f444 [pre-commit.ci] pre-commit autoupdate (#1168)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.4 → v0.8.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.4...v0.8.6)
- [github.com/pre-commit/mirrors-mypy: v1.14.0 → v1.14.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.14.0...v1.14.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-01-07 06:23:53 +01:00
pre-commit-ci[bot]
8c6c658f3a [pre-commit.ci] pre-commit autoupdate (#1167)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.3 → v0.8.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.3...v0.8.4)
- [github.com/pre-commit/mirrors-mypy: v1.13.0 → v1.14.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.13.0...v1.14.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-12-24 07:19:37 +01:00
pre-commit-ci[bot]
003d4e0a30 [pre-commit.ci] pre-commit autoupdate (#1166)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.2 → v0.8.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.2...v0.8.3)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Pierre Sassoulas <pierre.sassoulas@gmail.com>
2024-12-17 09:09:20 -03:00
🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко)
42d3204015 Merge pull request #1165 from pytest-dev/dependabot/github_actions/github-actions-45e9fa06d3
build(deps): bump hynek/build-and-inspect-python-package from 2.10 to 2.11 in the github-actions group
2024-12-17 02:49:53 +01:00
dependabot[bot]
5c1de86638 build(deps): bump hynek/build-and-inspect-python-package
Bumps the github-actions group with 1 update: [hynek/build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package).


Updates `hynek/build-and-inspect-python-package` from 2.10 to 2.11
- [Release notes](https://github.com/hynek/build-and-inspect-python-package/releases)
- [Changelog](https://github.com/hynek/build-and-inspect-python-package/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/build-and-inspect-python-package/compare/v2.10...v2.11)

---
updated-dependencies:
- dependency-name: hynek/build-and-inspect-python-package
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-16 17:43:16 +00:00
pre-commit-ci[bot]
6eb403d612 [pre-commit.ci] pre-commit autoupdate (#1164)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.1 → v0.8.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.1...v0.8.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-12-10 07:32:14 +01:00
🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко)
c9dce96b37 Merge pull request #1163 from pytest-dev/dependabot/github_actions/github-actions-088cad1836
build(deps): bump pypa/gh-action-pypi-publish from 1.12.2 to 1.12.3 in the github-actions group
2024-12-09 20:25:38 +01:00
dependabot[bot]
bcf9ebda2d build(deps): bump pypa/gh-action-pypi-publish
Bumps the github-actions group with 1 update: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `pypa/gh-action-pypi-publish` from 1.12.2 to 1.12.3
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.12.2...v1.12.3)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-09 17:37:00 +00:00
Bruno Oliveira
39c62b30a4 Drop EOL Python 3.8 support (#1162)
* Drop EOL Python 3.8 support

* Run pyupgrade --py39-plus on all files

* Revert using direct annotations in looponfail.py

We need to use string annotations otherwise we get this error:

```
ValueError: ("the use of non-builtin globals isn't supported", ['execnet', 'Any'])
```
2024-12-07 10:08:45 -03:00
Bruno Oliveira
31bee7c567 Revert using direct annotations in looponfail.py
We need to use string annotations otherwise we get this error:

```
ValueError: ("the use of non-builtin globals isn't supported", ['execnet', 'Any'])
```
2024-12-07 08:57:08 -03:00
Bruno Oliveira
475b346fd9 Run pyupgrade --py39-plus on all files 2024-12-07 08:47:25 -03:00
Bruno Oliveira
6dc8c2a66d Drop EOL Python 3.8 support 2024-12-07 08:47:25 -03:00
pre-commit-ci[bot]
c3d7b7784a [pre-commit.ci] pre-commit autoupdate (#1160)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.0 → v0.8.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.0...v0.8.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-12-02 21:41:02 -03:00
pre-commit-ci[bot]
b4eb2cae8f [pre-commit.ci] pre-commit autoupdate (#1158)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.7.4 → v0.8.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.4...v0.8.0)

* [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>
2024-11-26 08:59:28 +01:00
pre-commit-ci[bot]
26e3b35f7e [pre-commit.ci] pre-commit autoupdate (#1156)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.7.3 → v0.7.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.3...v0.7.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-11-19 07:30:56 +01:00
Ronny Pfannschmidt
65042b05da Merge pull request #1153 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2024-11-12 06:57:46 +01:00
pre-commit-ci[bot]
aca8dd4a18 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.7.2 → v0.7.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.2...v0.7.3)
2024-11-11 23:01:32 +00:00
Sviatoslav Sydorenko (Святослав Сидоренко)
7deba63fc8 Merge pull request #1152 from pytest-dev/dependabot/github_actions/github-actions-5da8da3d55 2024-11-11 19:27:53 +01:00
dependabot[bot]
a6d7ec19e2 build(deps): bump pypa/gh-action-pypi-publish
Bumps the github-actions group with 1 update: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `pypa/gh-action-pypi-publish` from 1.11.0 to 1.12.2
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.11.0...v1.12.2)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-11 17:39:50 +00:00
dependabot[bot]
0910472e2a build(deps): bump the github-actions group with 2 updates (#1149)
Bumps the github-actions group with 2 updates: [hynek/build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package) and [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `hynek/build-and-inspect-python-package` from 2.9 to 2.10
- [Release notes](https://github.com/hynek/build-and-inspect-python-package/releases)
- [Changelog](https://github.com/hynek/build-and-inspect-python-package/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/build-and-inspect-python-package/compare/v2.9...v2.10)

Updates `pypa/gh-action-pypi-publish` from 1.10.3 to 1.11.0
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.10.3...v1.11.0)

---
updated-dependencies:
- dependency-name: hynek/build-and-inspect-python-package
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-05 20:55:10 -03:00
pre-commit-ci[bot]
ec3ce293ef [pre-commit.ci] pre-commit autoupdate (#1150)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.7.1 → v0.7.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.1...v0.7.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-11-05 09:34:35 +01:00
Aleksandr Mezin
9788f12e9e Make 'steal' command atomic (#1144)
Either unschedule all requested tests, or none if it's not possible -
if some of the requested tests have already been processed by the time
the request arrives. It may happen if the worker runs tests faster than
the controller receives and processes status updates. But in this case
maybe it's just better to let the worker keep running.

This is a prerequisite for group/scope support in worksteal scheduler -
so they won't be broken up incorrectly.

This change could break schedulers that use "steal" command. However:

1) worksteal scheduler doesn't need any adjustments.

2) I'm not aware of any external schedulers relying on this command yet.

So I think it's better to keep the protocol simple, not complicate it for
imaginary compatibility with some unknown and likely non-existent
schedulers.

Co-authored-by: Bruno Oliveira <nicoddemus@gmail.com>
2024-10-30 14:00:10 -03:00
pre-commit-ci[bot]
34c554987a [pre-commit.ci] pre-commit autoupdate (#1147)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.7.0 → v0.7.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.0...v0.7.1)
- [github.com/asottile/blacken-docs: 1.19.0 → 1.19.1](https://github.com/asottile/blacken-docs/compare/1.19.0...1.19.1)
- [github.com/pre-commit/mirrors-mypy: v1.12.1 → v1.13.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.12.1...v1.13.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-10-28 22:46:37 -03:00
Sviatoslav Sydorenko (Святослав Сидоренко)
9c24f0fa4a Merge pull request #1143 from pytest-dev/pre-commit-ci-update-config 2024-10-22 01:30:42 +02:00
pre-commit-ci[bot]
def1f883ff [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.9 → v0.7.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.9...v0.7.0)
- [github.com/pre-commit/mirrors-mypy: v1.11.2 → v1.12.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.2...v1.12.1)
2024-10-21 23:02:01 +00:00
Ronny Pfannschmidt
469f31bc2d Merge pull request #1142 from edgarrmondragon/cp313
Added support for Python 3.13
2024-10-20 11:00:37 +02:00
Edgar Ramírez-Mondragón
3bf62e0e22 Added changelog entry 2024-10-19 08:57:03 -06:00
Edgar Ramírez-Mondragón
1c60dafcb2 Added support for Python 3.13 2024-10-19 08:56:01 -06:00
pre-commit-ci[bot]
a65df239f4 [pre-commit.ci] pre-commit autoupdate (#1137)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.8 → v0.6.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.8...v0.6.9)
- [github.com/asottile/blacken-docs: 1.18.0 → 1.19.0](https://github.com/asottile/blacken-docs/compare/1.18.0...1.19.0)
- [github.com/pre-commit/pre-commit-hooks: v4.6.0 → v5.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.6.0...v5.0.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-10-08 06:43:26 +02:00
dependabot[bot]
c483298478 build(deps): bump pypa/gh-action-pypi-publish (#1136)
Bumps the github-actions group with 1 update: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `pypa/gh-action-pypi-publish` from 1.10.2 to 1.10.3
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.10.2...v1.10.3)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 23:02:42 +02:00
pre-commit-ci[bot]
3d1f4b1e04 [pre-commit.ci] pre-commit autoupdate (#1135)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.7 → v0.6.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.7...v0.6.8)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-10-01 10:31:46 +02:00
pre-commit-ci[bot]
cbbb894e71 [pre-commit.ci] pre-commit autoupdate (#1133)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.5 → v0.6.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.5...v0.6.7)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-09-24 06:48:26 +02:00
dependabot[bot]
8b82eafcd4 build(deps): bump pypa/gh-action-pypi-publish (#1132)
Bumps the github-actions group with 1 update: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `pypa/gh-action-pypi-publish` from 1.10.1 to 1.10.2
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.10.1...v1.10.2)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-23 20:02:46 +02:00
pre-commit-ci[bot]
a66354ada5 [pre-commit.ci] pre-commit autoupdate (#1129)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.4 → v0.6.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.4...v0.6.5)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-09-17 21:00:47 -03:00
pre-commit-ci[bot]
b05a5bc7b8 [pre-commit.ci] pre-commit autoupdate (#1128)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.3 → v0.6.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.3...v0.6.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-09-09 20:28:12 -03:00
dependabot[bot]
38656eacef build(deps): bump the github-actions group with 2 updates (#1127)
Bumps the github-actions group with 2 updates: [hynek/build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package) and [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `hynek/build-and-inspect-python-package` from 2.8 to 2.9
- [Release notes](https://github.com/hynek/build-and-inspect-python-package/releases)
- [Changelog](https://github.com/hynek/build-and-inspect-python-package/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/build-and-inspect-python-package/compare/v2.8...v2.9)

Updates `pypa/gh-action-pypi-publish` from 1.10.0 to 1.10.1
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.10.0...v1.10.1)

---
updated-dependencies:
- dependency-name: hynek/build-and-inspect-python-package
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-09 18:22:54 -03:00
Bruno Oliveira
00231dce72 Publish package with attestations (#1125)
Follow up to https://github.com/pytest-dev/pytest-xdist/pull/1123.
2024-09-04 09:55:16 -03:00
pre-commit-ci[bot]
59866c7281 [pre-commit.ci] pre-commit autoupdate (#1124)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.2 → v0.6.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.2...v0.6.3)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-09-02 21:36:55 -03:00
dependabot[bot]
6f0620f559 build(deps): bump pypa/gh-action-pypi-publish (#1123)
Bumps the github-actions group with 1 update: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `pypa/gh-action-pypi-publish` from 1.9.0 to 1.10.0
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.9.0...v1.10.0)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-02 20:10:40 +02:00
pre-commit-ci[bot]
548280f737 [pre-commit.ci] pre-commit autoupdate (#1120)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.1 → v0.6.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.1...v0.6.2)
- [github.com/pre-commit/mirrors-mypy: v1.11.1 → v1.11.2](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.1...v1.11.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-08-26 23:28:07 -03:00
pre-commit-ci[bot]
5a7e87f06b [pre-commit.ci] pre-commit autoupdate (#1117)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.5.7 → v0.6.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.7...v0.6.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-08-19 20:34:24 -03:00
pre-commit-ci[bot]
649cca56bb [pre-commit.ci] pre-commit autoupdate (#1116)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.5.6 → v0.5.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.6...v0.5.7)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-08-13 06:24:04 +02:00
pre-commit-ci[bot]
629f3164a9 [pre-commit.ci] pre-commit autoupdate (#1115)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.5.5 → v0.5.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.5...v0.5.6)
- [github.com/pre-commit/mirrors-mypy: v1.11.0 → v1.11.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.0...v1.11.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-08-06 07:10:39 +02:00
dependabot[bot]
4902dc32e9 build(deps): bump hynek/build-and-inspect-python-package (#1112)
Bumps the github-actions group with 1 update: [hynek/build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package).


Updates `hynek/build-and-inspect-python-package` from 2.7 to 2.8
- [Release notes](https://github.com/hynek/build-and-inspect-python-package/releases)
- [Changelog](https://github.com/hynek/build-and-inspect-python-package/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/build-and-inspect-python-package/compare/v2.7...v2.8)

---
updated-dependencies:
- dependency-name: hynek/build-and-inspect-python-package
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-03 12:18:54 -03:00
pre-commit-ci[bot]
e377c3428c [pre-commit.ci] pre-commit autoupdate (#1113)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.5.4 → v0.5.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.4...v0.5.5)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-07-31 22:23:53 -03:00
Ronny Pfannschmidt
224d026e67 Merge pull request #1110 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2024-07-24 00:12:19 +02:00
Bruno Oliveira
569cb964d3 Adapt to mypy 1.11 2024-07-23 10:05:14 -03:00
pre-commit-ci[bot]
8230e4f1e1 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.5.2 → v0.5.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.2...v0.5.4)
- [github.com/pre-commit/mirrors-mypy: v1.10.1 → v1.11.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.1...v1.11.0)
2024-07-22 22:33:28 +00:00
dependabot[bot]
b417c792f1 build(deps): bump hynek/build-and-inspect-python-package (#1109)
Bumps the github-actions group with 1 update: [hynek/build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package).


Updates `hynek/build-and-inspect-python-package` from 2.6 to 2.7
- [Release notes](https://github.com/hynek/build-and-inspect-python-package/releases)
- [Changelog](https://github.com/hynek/build-and-inspect-python-package/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/build-and-inspect-python-package/compare/v2.6...v2.7)

---
updated-dependencies:
- dependency-name: hynek/build-and-inspect-python-package
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 15:35:56 -03:00
pre-commit-ci[bot]
d8efeb9f74 [pre-commit.ci] pre-commit autoupdate (#1105)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.5.0 → v0.5.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.0...v0.5.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-07-18 09:01:15 -03:00
dependabot[bot]
fabdbe3fd2 build(deps): bump pypa/gh-action-pypi-publish (#1100)
Bumps the github-actions group with 1 update: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `pypa/gh-action-pypi-publish` from 1.8.14 to 1.9.0
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.14...v1.9.0)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-02 08:41:58 -03:00
pre-commit-ci[bot]
f8eaa1483b [pre-commit.ci] pre-commit autoupdate (#1104)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.4.10 → v0.5.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.10...v0.5.0)
- [github.com/asottile/blacken-docs: 1.16.0 → 1.18.0](https://github.com/asottile/blacken-docs/compare/1.16.0...1.18.0)
- [github.com/pre-commit/mirrors-mypy: v1.10.0 → v1.10.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.0...v1.10.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-07-02 07:55:25 -03:00
pre-commit-ci[bot]
f1ae09176b [pre-commit.ci] pre-commit autoupdate (#1101)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.4.8 → v0.4.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.8...v0.4.10)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-06-27 09:27:07 -03:00
pre-commit-ci[bot]
c7b4f61144 [pre-commit.ci] pre-commit autoupdate (#1095)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.4.7 → v0.4.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.7...v0.4.8)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-06-10 21:58:13 -03:00
dependabot[bot]
39cb171341 build(deps): bump hynek/build-and-inspect-python-package (#1088)
Bumps the github-actions group with 1 update: [hynek/build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package).


Updates `hynek/build-and-inspect-python-package` from 2.5 to 2.6
- [Release notes](https://github.com/hynek/build-and-inspect-python-package/releases)
- [Changelog](https://github.com/hynek/build-and-inspect-python-package/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/build-and-inspect-python-package/compare/v2.5...v2.6)

---
updated-dependencies:
- dependency-name: hynek/build-and-inspect-python-package
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 21:58:00 -03:00
pre-commit-ci[bot]
98f2fc5982 [pre-commit.ci] pre-commit autoupdate (#1079)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.4.1 → v0.4.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.1...v0.4.7)
- [github.com/pre-commit/mirrors-mypy: v1.9.0 → v1.10.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.9.0...v1.10.0)

* Ignore "Use format specifiers instead of percent format" ruff error

No reason to force this in old code.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Bruno Oliveira <nicoddemus@gmail.com>
2024-06-04 09:59:02 -03:00
Nick Murphy
6f3f574c90 Clarify error message to indicate that users should refer to the documentation of pytest-xdist (#1092) 2024-06-04 08:50:20 -03:00
dependabot[bot]
7cc6515c06 build(deps): bump hynek/build-and-inspect-python-package (#1082)
Bumps the github-actions group with 1 update: [hynek/build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package).


Updates `hynek/build-and-inspect-python-package` from 2.4 to 2.5
- [Release notes](https://github.com/hynek/build-and-inspect-python-package/releases)
- [Changelog](https://github.com/hynek/build-and-inspect-python-package/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/build-and-inspect-python-package/compare/v2.4...v2.5)

---
updated-dependencies:
- dependency-name: hynek/build-and-inspect-python-package
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-16 07:38:54 -03:00
Ran Benita
e04e4ac30d Merge pull request #1077 from bluetech/tox-tweak
Some tox/build/config tweaks
2024-04-29 10:09:07 +03:00
Ran Benita
64469f56a3 MANIFEST.in: remove
I don't think a few unnecessary dot files bother anyone, and may
arguably be useful to someone.
2024-04-28 22:59:43 +03:00
Ran Benita
7c83841a3d pyproject: remove setuptools include-package-data = false
I compared the sdists and wheels and it makes no difference.
2024-04-28 22:54:31 +03:00
Ran Benita
6d54a6b2e3 pyproject: remove setuptools zip-safe key
According to
https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html#setuptools-specific-configuration
it's no longer relevant.
2024-04-28 22:52:30 +03:00
Ran Benita
a9a3d13a29 pyproject: remove setuptools platforms key
According to
https://packaging.python.org/en/latest/specifications/core-metadata/#platform-multiple-use
this is redundant with the classifiers.
2024-04-28 22:51:26 +03:00
Ran Benita
d204fdf965 pyproject: move pytest config from tox.ini 2024-04-28 22:48:51 +03:00
Ran Benita
fb51e6ba20 tox: some cleanups & remove hardcoded python3.10 2024-04-28 22:46:55 +03:00
Ran Benita
c143230d88 Merge pull request #1076 from pytest-dev/release-3.6.1
Release 3.6.1
2024-04-28 22:30:35 +03:00
Ran Benita
4dd2978031 Release 3.6.1 2024-04-28 22:20:45 +03:00
Ran Benita
b397288b7e Merge pull request #1072 from zmedico/gateway-cache-rinfo
Cache execnet gateway rinfo during WorkerController setup
2024-04-28 22:17:03 +03:00
Zac Medico
12b3cce0ce Cache execnet gateway rinfo during WorkerController setup
Cache execnet gateway info during WorkerController setup for backward
compatibility, in order to avoid a later main_thread_only deadlock
error triggered when pytest-cov calls rinfo after the main thread is
already busy. See pytest-dev/execnet#274 for corresponding test case.

Fixes: 20e3ac774e ("Use execnet main_thread_only execmodel (#1027)")
2024-04-28 22:01:32 +03:00
dependabot[bot]
c93a106b3a build(deps): bump hynek/build-and-inspect-python-package (#1066)
Bumps the github-actions group with 1 update: [hynek/build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package).


Updates `hynek/build-and-inspect-python-package` from 2.2 to 2.4
- [Release notes](https://github.com/hynek/build-and-inspect-python-package/releases)
- [Changelog](https://github.com/hynek/build-and-inspect-python-package/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/build-and-inspect-python-package/compare/v2.2...v2.4)

---
updated-dependencies:
- dependency-name: hynek/build-and-inspect-python-package
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 14:20:59 -03:00
pre-commit-ci[bot]
52e2022638 [pre-commit.ci] pre-commit autoupdate (#1073)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.3.7 → v0.4.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.7...v0.4.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-23 14:20:44 -03:00
Ran Benita
699f939b5c Merge pull request #1070 from pytest-dev/release-3.6.0
Release 3.6.0
2024-04-19 22:49:28 +03:00
Ran Benita
80bc0b8e5b Release 3.6.0 2024-04-19 22:39:59 +03:00
Zac Medico
20e3ac774e Use execnet main_thread_only execmodel (#1027)
Use the execnet main_thread_only execmodel so that code which expects
to run in the main thread will just work.  This execmodel has been
merged to the execnet master branch via pytest-dev/execnet#243, so this
patch should not be merged until there is a released version of execnet
supporting the main_thread_only execmodel.

Closes #620
2024-04-19 08:03:32 -03:00
Bruno Oliveira
0a4238f6da Merge pull request #1067 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2024-04-17 15:11:33 -03:00
pre-commit-ci[bot]
068627994f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.3.5 → v0.3.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.5...v0.3.7)
2024-04-17 15:09:14 -03:00
Ran Benita
ecf7c1cd10 Merge pull request #1065 from bluetech/typing
Improve typing
2024-04-16 23:36:41 +03:00
Ran Benita
b78cf1c0ce Improve typing
Fix #1057
2024-04-15 23:30:17 +03:00
Ran Benita
5dfc590a8c Merge pull request #1061 from bluetech/scheduling-proto
Add a Scheduling Protocol
2024-04-15 18:20:29 +03:00
Ran Benita
bf2dcccfb0 Add a Scheduling Protocol
- Clearly delineate the required interface of schedulers
- Useful for typing
2024-04-15 18:05:46 +03:00
Bruno Oliveira
91812bf5f9 Merge pull request #1062 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2024-04-10 11:08:51 -03:00
pre-commit-ci[bot]
7c3f70f007 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.5.0 → v4.6.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.5.0...v4.6.0)
2024-04-08 22:37:10 +00:00
Ran Benita
47208081d6 Merge pull request #1060 from bluetech/pre-typing-fixes
Pre-typing fixes/improvements
2024-04-06 22:26:31 +03:00
Ran Benita
cdff86a131 pyproject: make plain mypy run work 2024-04-06 21:55:44 +03:00
Ran Benita
29aa05f9bf testing: add worksteal to TestLocking tests
Seems like it intends to check all schedulers.
2024-04-06 21:55:44 +03:00
Ran Benita
d6c81d5537 pre-commit: remove py from typing deps, add types-psutil
py is no longer used.
psutil is used optionally so add its typing.
2024-04-06 21:55:42 +03:00
Ran Benita
e7a971b167 Use from __future__ import annotations
Allows us to use more modern typing.
2024-04-06 21:55:21 +03:00
Ran Benita
059c1bcc8c remote,workermanage: use enums for markers
Enum has a unique type, unlike `object()`, enabling better typing.
2024-04-06 21:54:44 +03:00
Ran Benita
ee0b09c61f loadscope: use dict instead of OrderedDict
These days dicts are guaranteed to be ordered, so no need to use
OrderedDict in two of the three cases (the remaining case needs
`popitem(last=False)`).
2024-04-06 21:54:44 +03:00
Ran Benita
024c73f29e testing: fix a private pytest import to its current location 2024-04-06 21:54:44 +03:00
Ran Benita
5c676aa685 dsession,workermanage: drop handling of pytest_captured_warning
Since d153e0a4c4 the remote doesn't send
events for this hook at all (I think perhaps wrongly, but it's history
by now), so no point in handling it in the coordinator side.
2024-04-06 21:54:44 +03:00
Ran Benita
a3b9a98126 looponfail: remove unneeded setup(out) parameter
It's not used.
2024-04-06 21:54:44 +03:00
Ran Benita
4057da1c1f remote: use perf_counter() to measure duration, not time()
`time()` is not monotonic and is not appropriate for measuring duration.
2024-04-06 21:54:44 +03:00
Ran Benita
ef5dcde977 each: remove incorrect mark_test_pending implementation
This code is bogus copy/paste; presumably it's not executed in practice
so make it raise `NotImplementedError`.
2024-04-06 21:54:44 +03:00
Ran Benita
503c5ee828 loadscope: remove redundant None check
self.config can't be None.
2024-04-06 21:54:44 +03:00
Ran Benita
8e77d14f8c testing: fix an assert
The assert as written was always true, according to the name `failures`
it intended the first element of the tuple.
2024-04-06 21:54:44 +03:00
Ran Benita
5dcc10c723 dsession: remove unnecessary getplugin("runner") 2024-04-06 21:54:44 +03:00
Ran Benita
3e9260462d Merge pull request #1059 from bluetech/execnet-min
Bump min execnet to 2.1.0
2024-04-06 21:51:30 +03:00
Ran Benita
e9084f2d73 Bump min execnet to 2.1.0
This version adds typing which we want to utilize.
2024-04-05 21:52:13 +03:00
Ran Benita
dd067e4f86 Merge pull request #1058 from bluetech/pytest-min-7
Bump min pytest to 7.0.0
2024-04-05 20:39:24 +03:00
Ran Benita
31de42ffa5 Bump min pytest to 7.0.0
Refs #1057
2024-04-05 11:50:11 +03:00
Ran Benita
99b767b6dd Merge pull request #1056 from bluetech/ci-pytestmin
ci: add a job for min supported pytest version
2024-04-05 10:58:02 +03:00
Ran Benita
fbf2eca13d ci: add a job for min supported pytest version
Make sure we don't break the promise.
2024-04-04 19:39:16 +03:00
Ran Benita
470bc83a25 Merge pull request #1052 from bluetech/pytester
testing: replace `testdir` -> `pytester`
2024-04-04 00:09:41 +03:00
Ronny Pfannschmidt
d96d87ef6f Merge pull request #1054 from bluetech/rm-py37
Drop support for Python 3.7
2024-04-03 21:56:21 +02:00
Ran Benita
a790dc5d72 Drop support for Python 3.7
Fix #1053
2024-04-03 18:07:38 +03:00
Ran Benita
d63400e8a6 testing: replace testdir -> pytester
The `testdir` fixture is soft-deprecated in favor of `pytester`.
2024-04-03 18:01:00 +03:00
Ran Benita
b6f5d2bd32 Merge pull request #1051 from bluetech/ruff
Use ruff instead of black, flake8, autoflake, pyupgrade
2024-04-03 09:42:26 +03:00
Ran Benita
816c9dcda1 Use ruff instead of black, flake8, autoflake, pyupgrade
Config adapted from pytest.
2024-04-03 00:19:32 +03:00
Ran Benita
c01de1c73e Make docstring style more consistent 2024-04-03 00:19:31 +03:00
Ran Benita
e3b69d4f29 Sort imports
Using pytest's style.
2024-04-03 00:19:31 +03:00
Bruno Oliveira
8f7bd68566 Merge pull request #1050 from pytest-dev/dependabot/github_actions/github-actions-867643949f
Bump the github-actions group with 1 update
2024-04-01 16:44:44 -03:00
dependabot[bot]
940c4fcf1d Bump the github-actions group with 1 update
Bumps the github-actions group with 1 update: [hynek/build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package).


Updates `hynek/build-and-inspect-python-package` from 2.0 to 2.2
- [Release notes](https://github.com/hynek/build-and-inspect-python-package/releases)
- [Changelog](https://github.com/hynek/build-and-inspect-python-package/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/build-and-inspect-python-package/compare/v2.0...v2.2)

---
updated-dependencies:
- dependency-name: hynek/build-and-inspect-python-package
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 17:25:28 +00:00
Ben Brown
84df4451ed Terminate the test loop if shouldfail or shouldstop is set (#1026) 2024-04-01 18:00:50 +03:00
Bruno Oliveira
dde8a66da5 Merge pull request #1047 from bluetech/rm-invocationparams-compat
Remove pytest InvocationParams backward compat
2024-04-01 07:36:30 -03:00
pre-commit-ci[bot]
74f5752ed9 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-03-23 18:42:36 +00:00
Ran Benita
00dc8eeaff Remove pytest InvocationParams backward compat
It's available since pytest 5.2, and pytest-xdist requires pytest>=6.2.
2024-03-23 20:40:54 +02:00
pre-commit-ci[bot]
74edbeb7c7 [pre-commit.ci] pre-commit autoupdate (#1044)
updates:
- [github.com/PyCQA/autoflake: v2.3.0 → v2.3.1](https://github.com/PyCQA/autoflake/compare/v2.3.0...v2.3.1)
- [github.com/psf/black: 24.2.0 → 24.3.0](https://github.com/psf/black/compare/24.2.0...24.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-03-19 12:27:16 -03:00
pre-commit-ci[bot]
5b83de49c5 [pre-commit.ci] pre-commit autoupdate (#1039)
updates:
- [github.com/pre-commit/mirrors-mypy: v1.8.0 → v1.9.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.8.0...v1.9.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-03-13 06:55:47 -03:00
dependabot[bot]
a9801a4214 Bump the github-actions group with 2 updates (#1037)
Bumps the github-actions group with 2 updates: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) and [softprops/action-gh-release](https://github.com/softprops/action-gh-release).


Updates `pypa/gh-action-pypi-publish` from 1.8.12 to 1.8.14
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.12...v1.8.14)

Updates `softprops/action-gh-release` from 1 to 2
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-12 09:52:45 -03:00
Albert Tugushev
74887e356d Fix incompatibility error with --pdb despite using -n0 (#938)
Fixes #937
2024-03-05 19:23:49 -03:00
dependabot[bot]
0596f2755b Bump the github-actions group with 1 update (#1035)
Bumps the github-actions group with 1 update: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `pypa/gh-action-pypi-publish` from 1.8.11 to 1.8.12
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.11...v1.8.12)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-04 15:15:58 -03:00
Ronny Pfannschmidt
54720c14f0 Merge pull request #1030 from jeremy-hiatt/dont-rewrite-path-hooks
Avoid modifying path placeholders created by editable installs
2024-03-01 07:34:14 +01:00
Jeremy Hiatt
79fb6ff9c0 testing: Add test to assert path hooks ignored in sys.path 2024-02-29 14:42:25 -08:00
Jeremy Hiatt
51ef94439c Avoid modifying path placeholders created by editable installs
The setuptools implementation of editable installs will insert a
placeholder entry into sys.path as part of its magic to register its
custom import mechanism.

These are not real filesystem paths and as such should not be
rewritten to absolute paths.
2024-02-28 15:17:12 -08:00
Bruno Oliveira
f57c65839b Fix '-n logical' inconsistencies and review --help formatting (#1022)
Fix #1021 and review formatting of the --help messages, following pytest's rules for consistency.
2024-02-25 11:49:41 -03:00
pre-commit-ci[bot]
ce9b01e50c [pre-commit.ci] pre-commit autoupdate (#1025)
updates:
- [github.com/PyCQA/autoflake: v2.2.1 → v2.3.0](https://github.com/PyCQA/autoflake/compare/v2.2.1...v2.3.0)
- [github.com/asottile/pyupgrade: v3.15.0 → v3.15.1](https://github.com/asottile/pyupgrade/compare/v3.15.0...v3.15.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-02-19 22:46:24 -03:00
pre-commit-ci[bot]
6ae08a04d2 [pre-commit.ci] pre-commit autoupdate (#1023)
updates:
- [github.com/psf/black: 24.1.1 → 24.2.0](https://github.com/psf/black/compare/24.1.1...24.2.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-02-13 13:28:30 -03:00
Michael McClelland
8d57046bd4 Update LICENSE formatting (#996)
Fixes issue with license file format to allow security scanners such as Black Duck to correctly identify the license type of this project as MIT.

Co-authored-by: Bruno Oliveira <bruno@soliv.dev>
2024-02-12 12:04:26 -03:00
Matthias Köppe
7132649eb5 Migrate to pyproject.toml (#1020)
Co-authored-by: Bruno Oliveira <bruno@soliv.dev>
2024-02-12 11:53:46 -03:00
dependabot[bot]
fe8c5a2012 Bump the github-actions group with 5 updates (#1016)
Bumps the github-actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `3` | `4` |
| [hynek/build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package) | `1.5` | `2.0` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `3` | `4` |
| [actions/setup-python](https://github.com/actions/setup-python) | `2` | `5` |
| [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) | `1.8.5` | `1.8.11` |


Updates `actions/checkout` from 3 to 4
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

Updates `hynek/build-and-inspect-python-package` from 1.5 to 2.0
- [Release notes](https://github.com/hynek/build-and-inspect-python-package/releases)
- [Changelog](https://github.com/hynek/build-and-inspect-python-package/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/build-and-inspect-python-package/compare/v1.5...v2.0)

Updates `actions/download-artifact` from 3 to 4
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v3...v4)

Updates `actions/setup-python` from 2 to 5
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v2...v5)

Updates `pypa/gh-action-pypi-publish` from 1.8.5 to 1.8.11
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.5...v1.8.11)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: hynek/build-and-inspect-python-package
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-31 15:56:27 -03:00
Dmitry Volkov
c2d31f5e8b Fix copyright year in project documentation (#898)
As per https://stackoverflow.com/questions/2390230/do-copyright-dates-need-to-be-updated, updated it to the first commit of this repository.
2024-01-31 15:44:46 -03:00
Christian Clauss
5ebf62a8bf Keep GitHub Actions up to date with GitHub's Dependabot (#1015)
Creates automatic pull requests to upgrade GitHub Actions
* https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
* https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
2024-01-31 15:41:13 -03:00
pre-commit-ci[bot]
3f27fd6a23 [pre-commit.ci] pre-commit autoupdate (#1013)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/psf/black: 23.12.1 → 24.1.1](https://github.com/psf/black/compare/23.12.1...24.1.1)

* [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>
2024-01-29 20:12:34 -03:00
pre-commit-ci[bot]
85421b926a [pre-commit.ci] pre-commit autoupdate (#1000)
updates:
- [github.com/PyCQA/flake8: 6.1.0 → 7.0.0](https://github.com/PyCQA/flake8/compare/6.1.0...7.0.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-01-09 21:12:48 -03:00
Ronny Pfannschmidt
56a1a11f2a Merge pull request #1003 from pytest-dev/fix-socketserver-link
Fix link to socketserver.py in the docs
2024-01-09 22:03:48 +01:00
Bruno Oliveira
b17011d098 Fix link to socketserver.py in the docs
Fix #1001
2024-01-09 17:34:16 -03:00
Ronny Pfannschmidt
f5573149fd Merge pull request #994 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-12-26 10:30:06 +01:00
pre-commit-ci[bot]
2d5f89ffc8 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 23.12.0 → 23.12.1](https://github.com/psf/black/compare/23.12.0...23.12.1)
- [github.com/pre-commit/mirrors-mypy: v1.7.1 → v1.8.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.7.1...v1.8.0)
2023-12-25 20:43:44 +00:00
pre-commit-ci[bot]
a5848d11b8 [pre-commit.ci] pre-commit autoupdate (#990)
updates:
- [github.com/psf/black: 23.11.0 → 23.12.0](https://github.com/psf/black/compare/23.11.0...23.12.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-12-19 07:46:54 -03:00
Bruno Oliveira
ef344e9b55 Add deploy instructions using the command-line (#967) 2023-12-01 11:28:21 -03:00
Ronny Pfannschmidt
5ec4d37f20 Merge pull request #978 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-11-27 22:21:26 +01:00
pre-commit-ci[bot]
cd7383bedb [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-mypy: v1.7.0 → v1.7.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.7.0...v1.7.1)
2023-11-27 20:21:38 +00:00
Bruno Oliveira
f67501bb26 Merge pull request #974 from pytest-dev/release-3.5.0
Release 3.5.0
2023-11-21 13:02:15 -03:00
Bruno Oliveira
95b309e980 Release 3.5.0 2023-11-21 11:33:54 -03:00
Henk-Jaap Wagenaar
3fe877bc6d LoadScope scheduler: Sort scopes by number of tests to assign biggest scopes first (#778)
Follow up to # 632.

---------

Co-authored-by: Jörg Kohlsdorf <joerg@cornershopapp.com>
2023-11-21 11:32:29 -03:00
Bruno Oliveira
f36ea25cb5 Create GitHub release after deploy (#973)
Fixes #972
2023-11-21 11:26:23 -03:00
Bruno Oliveira
d81f57508d Merge pull request #969 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-11-14 08:43:15 -03:00
pre-commit-ci[bot]
1d2616418c [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 23.10.1 → 23.11.0](https://github.com/psf/black/compare/23.10.1...23.11.0)
- [github.com/pre-commit/mirrors-mypy: v1.6.1 → v1.7.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.6.1...v1.7.0)
2023-11-13 21:01:19 +00:00
Bruno Oliveira
5f43575b42 Merge pull request #966 from pytest-dev/release-3.4.0
Release 3.4.0
2023-11-11 13:10:39 -03:00
Bruno Oliveira
a6b56112f9 Update CHANGELOG 2023-11-11 12:13:49 -03:00
Bruno Oliveira
64f9388f41 Add support for Python 3.12
Fix changelog entries from "improvement" to "feature" (there's "improvement" in our towncrier configuration).
2023-11-11 12:13:49 -03:00
Paul Ollis
230ba6ad10 Properly wait for workers when test run terminates early (#963)
Currently, a reason to terminate early (e.g. test failure with --exitfail
option set) causes DSession to immediately raise an Interrupt exception.
Subsequent reports generated by the workers, during the shutdown phase, are
discarded.

One consequence is that, for the failing test, teardown and testfinish are
ignored, which prevents corresponding hooks pytest_runtest_logreport and
pytest_runtest_logfinish being executed for the failing test (this problem
covered in #54). The reporting of tests executing in other workers is also left in an
indeterminate state. This can affect other plugin code.

This is a relatively simple fix, which appears to have minimal and, I think,
acceptable impact on text execution behaviour.

The observable differences are differences in what is reported about a test
run. For example, when running, for example with the '-x/--exitfail' option, it
is possible that more than a single test failure is reported. This is because
more than one test did fail before the test run was completely stopped. The
reporting is absolutely correct; and complete. Prior to this change, only a
single failure would have been reported, but because of incomplete and arguably
incorrect reporting.
2023-11-11 11:24:21 -03:00
Phillip Yen
93ca202b40 fix typo index -> instead 2023-11-04 14:24:08 -03:00
pre-commit-ci[bot]
8f3d1ad92f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 23.9.1 → 23.10.1](https://github.com/psf/black/compare/23.9.1...23.10.1)
- [github.com/pre-commit/mirrors-mypy: v1.6.0 → v1.6.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.6.0...v1.6.1)
2023-10-24 08:34:08 -03:00
Bruno Oliveira
ab3d6a9ee5 Merge pull request #955 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-10-17 09:08:19 -03:00
pre-commit-ci[bot]
a5210d4103 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-mypy: v1.5.1 → v1.6.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.5.1...v1.6.0)
2023-10-16 20:24:41 +00:00
Bruno Oliveira
0b6b9c3c38 Merge pull request #954 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-10-10 08:15:59 -03:00
Bruno Oliveira
838434c269 Install requirements.txt while building docs 2023-10-10 08:14:19 -03:00
Bruno Oliveira
b85a4717f9 Add docs/requirements.txt 2023-10-10 08:13:46 -03:00
pre-commit-ci[bot]
b2770e65e1 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.5.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.5.0)
- [github.com/asottile/pyupgrade: v3.14.0 → v3.15.0](https://github.com/asottile/pyupgrade/compare/v3.14.0...v3.15.0)
2023-10-09 21:00:06 +00:00
pre-commit-ci[bot]
4a3a8b499b [pre-commit.ci] pre-commit autoupdate (#953)
updates:
- [github.com/asottile/pyupgrade: v3.13.0 → v3.14.0](https://github.com/asottile/pyupgrade/compare/v3.13.0...v3.14.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-10-03 09:28:00 -03:00
pre-commit-ci[bot]
4016c2a173 [pre-commit.ci] pre-commit autoupdate (#951)
updates:
- [github.com/asottile/pyupgrade: v3.11.0 → v3.13.0](https://github.com/asottile/pyupgrade/compare/v3.11.0...v3.13.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-09-26 06:58:37 -03:00
pre-commit-ci[bot]
ddc28f3870 [pre-commit.ci] pre-commit autoupdate (#949)
updates:
- [github.com/asottile/pyupgrade: v3.10.1 → v3.11.0](https://github.com/asottile/pyupgrade/compare/v3.10.1...v3.11.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-09-19 10:10:24 -03:00
pre-commit-ci[bot]
02ddfc9860 [pre-commit.ci] pre-commit autoupdate (#948)
updates:
- [github.com/psf/black: 23.7.0 → 23.9.1](https://github.com/psf/black/compare/23.7.0...23.9.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-09-12 07:50:26 -03:00
Bruno Oliveira
f15de24e5a Merge pull request #946 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-09-05 07:24:48 -03:00
Bruno Oliveira
49a1093574 Add readthedocs configuration file -- now required 2023-09-05 07:18:41 -03:00
Bruno Oliveira
470f4ee53c Fix warn_on_impl parameter on newhooks.py 2023-09-05 07:18:11 -03:00
pre-commit-ci[bot]
733d769bc0 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/PyCQA/autoflake: v2.2.0 → v2.2.1](https://github.com/PyCQA/autoflake/compare/v2.2.0...v2.2.1)
2023-09-05 08:25:26 +00:00
pre-commit-ci[bot]
8ff5c62cfe [pre-commit.ci] pre-commit autoupdate (#941)
updates:
- [github.com/asottile/blacken-docs: 1.15.0 → 1.16.0](https://github.com/asottile/blacken-docs/compare/1.15.0...1.16.0)
- [github.com/pre-commit/mirrors-mypy: v1.5.0 → v1.5.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.5.0...v1.5.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-08-26 16:38:37 -03:00
Bruno Oliveira
6b52e0a23d Merge pull request #939 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-08-15 09:26:03 -03:00
pre-commit-ci[bot]
d5313c5660 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-mypy: v1.4.1 → v1.5.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.4.1...v1.5.0)
2023-08-15 07:24:55 +00:00
Bruno Oliveira
bfd28ff398 Merge pull request #934 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-08-02 09:51:08 -03:00
Ronny Pfannschmidt
17da7cbe59 correct the type compare the updated flake8 found 2023-08-01 09:12:00 +00:00
pre-commit-ci[bot]
caff114004 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/PyCQA/flake8: 6.0.0 → 6.1.0](https://github.com/PyCQA/flake8/compare/6.0.0...6.1.0)
- [github.com/asottile/pyupgrade: v3.9.0 → v3.10.1](https://github.com/asottile/pyupgrade/compare/v3.9.0...v3.10.1)
2023-08-01 08:52:03 +00:00
pre-commit-ci[bot]
6f0cb26fc0 [pre-commit.ci] pre-commit autoupdate (#929)
updates:
- [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0)
- [github.com/asottile/blacken-docs: 1.14.0 → 1.15.0](https://github.com/asottile/blacken-docs/compare/1.14.0...1.15.0)
- [github.com/asottile/pyupgrade: v3.8.0 → v3.9.0](https://github.com/asottile/pyupgrade/compare/v3.8.0...v3.9.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-07-11 07:58:21 -03:00
Bruno Oliveira
5661f498c7 Docs: clarify that only rsync support is deprecated (#927)
Related to #839
2023-07-10 07:53:38 -03:00
Ronny Pfannschmidt
380f978243 Merge pull request #926 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-07-04 09:29:28 +02:00
pre-commit-ci[bot]
f609fb7de4 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.7.0 → v3.8.0](https://github.com/asottile/pyupgrade/compare/v3.7.0...v3.8.0)
2023-07-04 06:41:04 +00:00
pre-commit-ci[bot]
03a64d91f3 [pre-commit.ci] pre-commit autoupdate (#923)
updates:
- [github.com/PyCQA/autoflake: v2.1.1 → v2.2.0](https://github.com/PyCQA/autoflake/compare/v2.1.1...v2.2.0)
- [github.com/pre-commit/mirrors-mypy: v1.3.0 → v1.4.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.3.0...v1.4.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-06-27 08:07:12 -03:00
pre-commit-ci[bot]
7d14d50f65 [pre-commit.ci] pre-commit autoupdate (#920)
updates:
- [github.com/asottile/blacken-docs: 1.13.0 → 1.14.0](https://github.com/asottile/blacken-docs/compare/1.13.0...1.14.0)
- [github.com/asottile/pyupgrade: v3.6.0 → v3.7.0](https://github.com/asottile/pyupgrade/compare/v3.6.0...v3.7.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-06-20 07:56:49 -03:00
Bruno Oliveira
56a4f212b7 Merge pull request #918 from pytest-dev/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-06-13 08:22:42 -03:00
pre-commit-ci[bot]
a4c887b058 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.4.0 → v3.6.0](https://github.com/asottile/pyupgrade/compare/v3.4.0...v3.6.0)
2023-06-13 05:13:50 +00:00
Bruno Oliveira
7e1768f838 Merge pull request #910 from pytest-dev/release-3.3.1
Release 3.3.1
2023-05-19 07:55:03 -03:00
Bruno Oliveira
9791021f02 Release 3.3.1 2023-05-19 07:44:21 -03:00
Bruno Oliveira
4e7bd0239e Avoid unnecessary rinfo calls after creating gateways (#909)
Hopefully this fixes #907, as seems this is the only
change in #901 which is somehow related.

---------

Co-authored-by: Ronny Pfannschmidt <opensource@ronnypfannschmidt.de>
2023-05-19 07:43:18 -03:00
pre-commit-ci[bot]
52a61434fb [pre-commit.ci] pre-commit autoupdate (#908)
updates:
- [github.com/pre-commit/mirrors-mypy: v1.2.0 → v1.3.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.2.0...v1.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-05-16 06:20:14 -03:00
Bruno Oliveira
9efc64eef9 Merge pull request #906 from nicoddemus/ci-improvements
CI improvements
2023-05-15 08:21:30 -03:00
Bruno Oliveira
6d39025be3 Use same package in all test runs 2023-05-12 17:59:46 -03:00
Bruno Oliveira
d8fb60ba7f Fix tag creation in deploy workflow
As commented in #905, an annotated tag needs a configured user.
2023-05-12 17:52:21 -03:00
Bruno Oliveira
4d230b6de7 Merge pull request #905 from pytest-dev/release-3.3.0
Release 3.3.0
2023-05-12 17:48:57 -03:00
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
48 changed files with 3205 additions and 1166 deletions

13
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# Keep GitHub Actions up to date with GitHub's Dependabot...
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
version: 2
updates:
- package-ecosystem: github-actions
directory: /
groups:
github-actions:
patterns:
- "*" # Group all Actions updates into a single larger pull request
schedule:
interval: weekly

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

@@ -0,0 +1,57 @@
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@v4
- name: Build and Check Package
uses: hynek/build-and-inspect-python-package@v2.13
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@v4
- name: Download Package
uses: actions/download-artifact@v4
with:
name: Packages
path: dist
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@v1.12.4
with:
attestations: true
- name: Push tag
run: |
git config user.name "pytest bot"
git config user.email "pytestbot@gmail.com"
git tag --annotate --message=v${{ github.event.inputs.version }} v${{ github.event.inputs.version }} ${{ github.sha }}
git push origin v${{ github.event.inputs.version }}
- name: GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/*
tag_name: 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 }}

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

@@ -0,0 +1,93 @@
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:
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and Check Package
uses: hynek/build-and-inspect-python-package@v2.13
test:
needs: [package]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
tox_env:
- "py39-pytestmin"
- "py39-pytestlatest"
- "py310-pytestlatest"
- "py311-pytestlatest"
- "py311-pytestmain"
- "py312-pytestlatest"
- "py313-pytestlatest"
- "py310-psutil"
- "py310-setproctitle"
os: [ubuntu-latest, windows-latest]
include:
- tox_env: "py39-pytestmin"
python: "3.9"
- tox_env: "py39-pytestlatest"
python: "3.9"
- tox_env: "py310-pytestlatest"
python: "3.10"
- tox_env: "py311-pytestlatest"
python: "3.11"
- tox_env: "py311-pytestmain"
python: "3.11"
- tox_env: "py312-pytestlatest"
python: "3.12"
- tox_env: "py313-pytestlatest"
python: "3.13"
- tox_env: "py310-psutil"
python: "3.10"
- tox_env: "py310-setproctitle"
python: "3.10"
steps:
- uses: actions/checkout@v4
with:
# Needed to fetch tags, which are required by setuptools-scm.
fetch-depth: 0
- name: Download Package
uses: actions/download-artifact@v4
with:
name: Packages
path: dist
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Install tox
run: |
python -m pip install --upgrade pip
pip install tox
- name: Test
shell: bash
run: |
tox run -e ${{ matrix.tox_env }} --installpkg `find dist/*.tar.gz`

View File

@@ -1,35 +1,19 @@
repos: repos:
- repo: https://github.com/PyCQA/autoflake - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v1.7.6 rev: "v0.12.3"
hooks: hooks:
- id: autoflake - id: ruff
args: ["--in-place", "--remove-unused-variables", "--remove-all-unused-imports"] args: ["--fix"]
- repo: https://github.com/psf/black - id: ruff-format
rev: 22.3.0
hooks:
- id: black
args: [--safe, --quiet, --target-version, py35]
- repo: https://github.com/asottile/blacken-docs - repo: https://github.com/asottile/blacken-docs
rev: v1.12.1 rev: 1.19.1
hooks: hooks:
- id: blacken-docs - id: blacken-docs
additional_dependencies: [black==20.8b1] 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.2.0 rev: v5.0.0
hooks: hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: debug-statements
- repo: https://github.com/PyCQA/flake8
rev: 4.0.1
hooks:
- id: flake8
- repo: https://github.com/asottile/pyupgrade
rev: v2.32.1
hooks:
- id: pyupgrade
args: [--py3-plus]
- repo: local - repo: local
hooks: hooks:
- id: rst - id: rst
@@ -39,11 +23,13 @@ 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.960 rev: v1.16.1
hooks: hooks:
- id: mypy - id: mypy
files: ^(src/|testing/) files: ^(src/|testing/)
args: [] args: []
additional_dependencies: additional_dependencies:
- pytest>=6.2.0 - pytest>=7.0.0
- py>=1.10.0 - execnet>=2.1.0
- types-psutil
- setproctitle

14
.readthedocs.yaml Normal file
View File

@@ -0,0 +1,14 @@
version: 2
build:
os: ubuntu-22.04
tools:
python: "3.11"
sphinx:
configuration: docs/conf.py
python:
install:
- path: .
- requirements: docs/requirements.txt

View File

@@ -1,4 +1,206 @@
pytest-xdist 3.0.0 (2022-10-24) pytest-xdist 3.8.0 (2025-06-30)
===============================
Features
--------
- `#1083 <https://github.com/pytest-dev/pytest-xdist/issues/1083>`_: Add ``--no-loadscope-reorder`` and ``--loadscope-reorder`` option to control whether to automatically reorder tests in loadscope for tests where relative ordering matters. This only applies when using ``loadscope``.
For example, [test_file_1, test_file_2, ..., test_file_n] are given as input test files, if ``--no-loadscope-reorder`` is used, for either worker, the ``test_file_a`` will be executed before ``test_file_b`` only if ``a < b``.
The default behavior is to reorder the tests to maximize the number of tests that can be executed in parallel.
pytest-xdist 3.7.0 (2025-05-26)
===============================
Features
--------
- `#1142 <https://github.com/pytest-dev/pytest-xdist/issues/1142>`_: Added support for Python 3.13.
- `#1144 <https://github.com/pytest-dev/pytest-xdist/issues/1144>`_: The internal `steal` command is now atomic - it unschedules either all requested tests or none.
This is a prerequisite for group/scope support in the `worksteal` scheduler, so test groups won't be broken up incorrectly.
- `#1170 <https://github.com/pytest-dev/pytest-xdist/issues/1170>`_: Add the `--px` arg to create proxy gateways.
Proxy gateways are passed to additional gateways using the `via` keyword.
They can serve as a way to run multiple workers on remote machines.
- `#1200 <https://github.com/pytest-dev/pytest-xdist/issues/1200>`_: Now multiple ``xdist_group`` markers are considered when assigning tests to groups (order does not matter).
Previously, only the last marker would assign a test to a group, but now if a test has multiple ``xdist_group`` marks applied (for example via parametrization or via fixtures), they are merged to make a new group.
Removals
--------
- `#1162 <https://github.com/pytest-dev/pytest-xdist/issues/1162>`_: Dropped support for EOL Python 3.8.
Trivial Changes
---------------
- `#1092 <https://github.com/pytest-dev/pytest-xdist/issues/1092>`_: Update an error message to better indicate where users should go for more information.
- `#1190 <https://github.com/pytest-dev/pytest-xdist/issues/1190>`_: Switched to using a SPDX license identifier introduced in PEP 639.
pytest-xdist 3.6.1 (2024-04-28)
===============================
Bug Fixes
---------
- `#1071 <https://github.com/pytest-dev/pytest-xdist/issues/1071>`_: Add backward compatibility for deadlock issue with the ``execnet`` new ``main_thread_only`` "execmodel" triggered when pytest-cov accesses rinfo.
pytest-xdist 3.6.0 (2024-04-19)
===============================
This release was YANKED due to a regression fixed in 3.6.1.
Features
--------
- `#1027 <https://github.com/pytest-dev/pytest-xdist/pull/1027>`_:``pytest-xdist`` workers now always execute the tests in the main thread.
Previously some tests might end up executing in a separate thread other than ``main`` in the workers, due to some internal `execnet`` details. This can cause problems specially with async frameworks where the event loop is running in the ``main`` thread (for example `#620 <https://github.com/pytest-dev/pytest-xdist/issues/620>`__).
Bug Fixes
---------
- `#1024 <https://github.com/pytest-dev/pytest-xdist/issues/1024>`_: Added proper handling of ``shouldstop`` (such as set by ``--max-fail``) and ``shouldfail`` conditions in workers.
Previously, a worker might have continued executing further tests before the controller could terminate the session.
- `#1028 <https://github.com/pytest-dev/pytest-xdist/issues/1028>`_: Fixed compatibility issue between `looponfail` and editable installs.
- `#620 <https://github.com/pytest-dev/pytest-xdist/issues/620>`_: Use the new ``main_thread_only`` ``execnet`` "execmodel" so that code which expects to only run in the main thread will now work as expected.
- `#937 <https://github.com/pytest-dev/pytest-xdist/issues/937>`_: Fixed a bug where plugin would raise an incompatibility error with ``--pdb`` despite using ``-n0``.
Removals
--------
- `#1053 <https://github.com/pytest-dev/pytest-xdist/issues/1053>`_: Dropped support for Python 3.7.
- `#1057 <https://github.com/pytest-dev/pytest-xdist/issues/1057>`_: pytest>=7.0.0 is now required.
execnet>=2.1.0 is now required.
Trivial Changes
---------------
- `#1020 <https://github.com/pytest-dev/pytest-xdist/issues/1020>`_: pytest-xdist's ``setup.py`` file is removed.
If you relied on this file, e.g. to install pytest using ``setup.py install``,
please see `Why you shouldn't invoke setup.py directly <https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html#summary>`_ for alternatives.
- `#1057 <https://github.com/pytest-dev/pytest-xdist/issues/1057>`_: The internals of pytest-xdist are now fully typed. The typing is not exposed yet.
- `#996 <https://github.com/pytest-dev/pytest-xdist/issues/996>`_: Adjusted license file format and content to ensure security scanners will identity the license.
pytest-xdist 3.5.0 (2023-11-21)
===============================
Features
--------
- `#632 <https://github.com/pytest-dev/pytest-xdist/issues/632>`_: ``--dist=loadscope`` now sorts scopes by number of tests to assign largest scopes early -- in many cases this should improve overall test session running time, as there is less chance of a large scope being left to be processed near the end of the session, leaving other workers idle.
pytest-xdist 3.4.0 (2023-11-11)
===============================
Features
--------
- `#963 <https://github.com/pytest-dev/pytest-xdist/issues/963>`_: Wait for workers to finish reporting when test run stops early.
This makes sure that the results of in-progress tests are displayed.
Previously these reports were being discarded, losing information about the
test run.
- `#965 <https://github.com/pytest-dev/pytest-xdist/issues/965>`_: Added support for Python 3.12.
pytest-xdist 3.3.1 (2023-05-19)
===============================
Bug Fixes
---------
- `#907 <https://github.com/pytest-dev/pytest-xdist/issues/907>`_: Avoid remote calls during startup as ``execnet`` by default does not ensure remote affinity with the
main thread and might accidentally schedule the pytest worker into a non-main thread, which breaks numerous frameworks,
for example ``asyncio``, ``anyio``, ``PyQt/PySide``, etc.
A more safe correction will require thread affinity in ``execnet`` (`pytest-dev/execnet#96 <https://github.com/pytest-dev/execnet/issues/96>`__).
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 Bug Fixes

33
LICENSE
View File

@@ -1,18 +1,21 @@
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy Copyright (c) 2010 Holger Krekel and contributors.
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all Permission is hereby granted, free of charge, to any person obtaining a copy
copies or substantial portions of the Software. of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR The above copyright notice and this permission notice shall be included in all
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, copies or substantial portions of the Software.
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
SOFTWARE. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,5 +0,0 @@
exclude .appveyor.yml
exclude .gitignore
exclude .pre-commit-config.yaml
exclude .travis.yml
prune .github

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

View File

@@ -32,14 +32,10 @@ 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, or execute::
$ export VERSION=X.Y.Z gh workflow run deploy.yml -R pytest-dev/pytest-xdist --ref release-X.Y.Z --field version=X.Y.Z
$ 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``.
#. Merge the release PR to `master`.

View File

@@ -18,7 +18,7 @@
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
project = "pytest-xdist" project = "pytest-xdist"
copyright = "2022, holger krekel and contributors" copyright = "2010, holger krekel and contributors"
author = "holger krekel and contributors" author = "holger krekel and contributors"
master_doc = "index" master_doc = "index"

View File

@@ -11,14 +11,16 @@ This can lead to considerable speed ups, especially if your test suite takes a
noticeable amount of time. noticeable amount of time.
With ``-n auto``, pytest-xdist will use as many processes as your computer With ``-n auto``, pytest-xdist will use as many processes as your computer
has CPU cores. has physical CPU cores.
Use ``-n logical`` to use the number of *logical* CPU cores rather than Use ``-n logical`` to use the number of *logical* CPU cores rather than
physical ones. This currently requires the ``psutils`` package to be installed; physical ones. This currently requires the `psutil <https://pypi.org/project/psutil/>`__ package to be installed;
if it is not, pytest-xdist will fall back to ``-n auto`` behavior. if it is not or if it fails to determine the number of logical CPUs, fall back to ``-n auto`` behavior.
Pass a number, e.g. ``-n 8``, to specify the number of processes explicitly. Pass a number, e.g. ``-n 8``, to specify the number of processes explicitly.
Use ``-n 0`` to disable xdist and run all tests in the main process.
To specify a different meaning for ``-n auto`` and ``-n logical`` for your To specify a different meaning for ``-n auto`` and ``-n logical`` for your
tests, you can: tests, you can:
@@ -50,7 +52,8 @@ The test distribution algorithm is configured with the ``--dist`` command-line o
.. _distribution modes: .. _distribution modes:
* ``--dist load`` **(default)**: Sends pending tests to any worker that is * ``--dist load`` **(default)**: Sends pending tests to any worker that is
available, without any guaranteed order. 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* * ``--dist loadscope``: Tests are grouped by **module** for *test functions*
and by **class** for *test methods*. Groups are distributed to available and by **class** for *test methods*. Groups are distributed to available
@@ -65,7 +68,10 @@ The test distribution algorithm is configured with the ``--dist`` command-line o
* ``--dist loadgroup``: Tests are grouped by the ``xdist_group`` mark. Groups are * ``--dist loadgroup``: Tests are grouped by the ``xdist_group`` mark. Groups are
distributed to available workers as whole units. This guarantees that all distributed to available workers as whole units. This guarantees that all
tests with same ``xdist_group`` name run in the same worker. tests with same ``xdist_group`` name run in the same worker. If a test has
multiple groups, they will be joined together into a new group,
the order of the marks doesn't matter. This works along with marks from fixtures
and from the pytestmark global variable.
.. code-block:: python .. code-block:: python
@@ -80,6 +86,45 @@ The test distribution algorithm is configured with the ``--dist`` command-line o
pass pass
This will make sure ``test1`` and ``TestA::test2`` will run in the same worker. This will make sure ``test1`` and ``TestA::test2`` will run in the same worker.
.. code-block:: python
@pytest.fixture(
scope="session",
params=[
pytest.param(
"chrome",
marks=pytest.mark.xdist_group("chrome"),
),
pytest.param(
"firefox",
marks=pytest.mark.xdist_group("firefox"),
),
pytest.param(
"edge",
marks=pytest.mark.xdist_group("edge"),
),
],
)
def setup_container():
pass
@pytest.mark.xdist_group(name="data-store")
def test_data_store(setup_container):
...
This will generate 3 new groups: ``chrome_data-store``, ``data-store_firefox`` and ``data-store_edge`` (the markers are lexically sorted before being merged together).
Tests without the ``xdist_group`` mark are distributed normally as in the ``--dist=load`` mode. 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). * ``--dist no``: The normal pytest execution mode, runs one test at a time (no distribution at all).

View File

@@ -29,8 +29,8 @@ The execution flow is:
corresponds to the position of that test in the original collection corresponds to the position of that test in the original collection
list. This works because all nodes have the same collection list, and list. This works because all nodes have the same collection list, and
saves bandwidth because the **controller** can now tell one of the saves bandwidth because the **controller** can now tell one of the
workers to just *execute test index 3* index of passing the full test workers to just *execute test index 3* instead of passing the full
id. test id.
4. If **dist-mode** is **each**: the **controller** just sends the full 4. If **dist-mode** is **each**: the **controller** just sends the full
list of test indexes to each node at this moment. list of test indexes to each node at this moment.

View File

@@ -15,7 +15,7 @@ a test or fixture, you may use the ``worker_id`` fixture to do so:
@pytest.fixture() @pytest.fixture()
def user_account(worker_id): def user_account(worker_id):
""" use a different account in each xdist worker """ """use a different account in each xdist worker"""
return "account_%s" % worker_id return "account_%s" % worker_id
When ``xdist`` is disabled (running with ``-n0`` for example), then When ``xdist`` is disabled (running with ``-n0`` for example), then
@@ -80,7 +80,7 @@ wanted to create a separate database for each test run:
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def create_unique_database(testrun_uid): def create_unique_database(testrun_uid):
""" create a unique database for this particular test run """ """create a unique database for this particular test run"""
database_url = f"psql://myapp-{testrun_uid}" database_url = f"psql://myapp-{testrun_uid}"
with Semaphore(f"/{testrun_uid}-lock", flags=O_CREAT, initial_value=1): with Semaphore(f"/{testrun_uid}-lock", flags=O_CREAT, initial_value=1):
@@ -90,7 +90,7 @@ wanted to create a separate database for each test run:
@pytest.fixture() @pytest.fixture()
def db(testrun_uid): def db(testrun_uid):
""" retrieve unique database """ """retrieve unique database"""
database_url = f"psql://myapp-{testrun_uid}" database_url = f"psql://myapp-{testrun_uid}"
return database_get_instance(database_url) return database_get_instance(database_url)
@@ -221,7 +221,6 @@ Example:
def pytest_configure(config): def pytest_configure(config):
worker_id = os.environ.get("PYTEST_XDIST_WORKER") worker_id = os.environ.get("PYTEST_XDIST_WORKER")
if worker_id is not None: if worker_id is not None:
log_file = config.getini("worker_log_file")
logging.basicConfig( logging.basicConfig(
format=config.getini("log_file_format"), format=config.getini("log_file_format"),
filename=f"tests_{worker_id}.log", filename=f"tests_{worker_id}.log",

View File

@@ -6,7 +6,7 @@ pytest-xdist has some limitations that may be supported in pytest but can't be s
Order and amount of test must be consistent Order and amount of test must be consistent
------------------------------------------- -------------------------------------------
Is is not possible to have tests that differ in order or their amount across workers. 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. This is especially true with ``pytest.mark.parametrize``, when values are produced with sets or other unordered iterables/generators.
@@ -59,6 +59,13 @@ 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. 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. 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.
Currenlty there are no plans ot support this in ``pytest-xdist``. 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.

View File

@@ -9,13 +9,16 @@ Sending tests to remote SSH accounts
.. warning:: .. warning::
This feature is deprecated because the support for ``rsync`` is faulty The ``rsync`` feature is deprecated because its implementation is faulty
in terms of reproducing the development environment in the remote in terms of reproducing the development environment in the remote
worker, and there is no clear solution moving forward. worker, and there is no clear solution moving forward.
This feature is scheduled to be removed in release 4.0, to let the team For that reason, ``rsync`` is scheduled to be removed in release 4.0, to let the team
focus on a smaller set of features. focus on a smaller set of features.
Note that SSH and socket server are not planned for removal, as they are part
of the ``execnet`` feature set.
Suppose you have a package ``mypkg`` which contains some Suppose you have a package ``mypkg`` which contains some
tests that you can successfully run locally. And you tests that you can successfully run locally. And you
have a ssh-reachable machine ``myhost``. Then have a ssh-reachable machine ``myhost``. Then
@@ -63,6 +66,20 @@ new socket host with something like this::
pytest -d --tx socket=192.168.1.102:8888 --rsyncdir mypkg pytest -d --tx socket=192.168.1.102:8888 --rsyncdir mypkg
Using proxies to run multiple workers on remote machines
---------------------------------------
In case you want to run multiple workers on a remote machine,
you can create a proxy gateway for the machine, and run multiple
workers using the `via` attribute.::
pytest -d --px id=my_proxy//socket=192.168.1.102:8888 --tx 5*popen//via=my_proxy
Here we declare a proxy gateway using the `--px` arg, and
create 5 workers that run on the remote server using the proxy.
Note that the proxy gateway does not run a worker, thus only 5
workers are created.
Running tests on many platforms at once Running tests on many platforms at once
--------------------------------------- ---------------------------------------
@@ -80,4 +97,4 @@ at once. The specifications strings use the `xspec syntax`_.
.. _`execnet`: https://codespeak.net/execnet .. _`execnet`: https://codespeak.net/execnet
.. _`socketserver.py`: https://raw.githubusercontent.com/pytest-dev/execnet/master/execnet/script/socketserver.py .. _`socketserver.py`: https://raw.githubusercontent.com/pytest-dev/execnet/master/src/execnet/script/socketserver.py

2
docs/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
sphinx
sphinx-rtd-theme

View File

@@ -1,14 +1,141 @@
[build-system] [build-system]
requires = [ requires = [
# sync with setup.py until we discard non-pep-517/518 "setuptools>=77.0",
"setuptools>=45.0",
"setuptools-scm[toml]>=6.2.3",
"wheel",
] ]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.setuptools_scm] [project]
write_to = "src/xdist/_version.py" name = "pytest-xdist"
version = "3.7.1.dev24+g2b4372bd6"
description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs"
readme = "README.rst"
license = "MIT"
authors = [{name = "holger krekel and contributors", email = "pytest-dev@python.org"}, {email = "holger@merlinux.eu"}]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Framework :: Pytest",
"Intended Audience :: Developers",
"Operating System :: POSIX",
"Operating System :: Microsoft :: Windows",
"Operating System :: MacOS :: MacOS X",
"Topic :: Software Development :: Testing",
"Topic :: Software Development :: Quality Assurance",
"Topic :: Utilities",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
requires-python = ">=3.9"
dependencies = [
"execnet>=2.1",
"pytest>=7.0.0",
]
[project.urls]
Homepage = "https://github.com/pytest-dev/pytest-xdist"
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"
[project.entry-points.pytest11]
xdist = "xdist.plugin"
"xdist.looponfail" = "xdist.looponfail"
[project.optional-dependencies]
testing = ["filelock"]
psutil = ["psutil>=3.0"]
setproctitle = ["setproctitle"]
[tool.pytest.ini_options]
# pytest-services also defines a worker_id fixture, disable
# it so they don't conflict with each other (#611).
addopts = "-ra -p no:pytest-services"
testpaths = ["testing"]
[tool.ruff]
src = ["src"]
[tool.ruff.format]
docstring-code-format = true
[tool.ruff.lint]
select = [
"B", # bugbear
"D", # pydocstyle
"E", # pycodestyle
"F", # pyflakes
"I", # isort
"PYI", # flake8-pyi
"UP", # pyupgrade
"RUF", # ruff
"W", # pycodestyle
"T10", # flake8-debugger
"PIE", # flake8-pie
"FA", # flake8-future-annotations
"PGH", # pygrep-hooks
"PLE", # pylint error
"PLW", # pylint warning
"PLR1714", # Consider merging multiple comparisons
]
ignore = [
# bugbear ignore
"B011", # Do not `assert False` (`python -O` removes these calls)
"B028", # No explicit `stacklevel` keyword argument found
# pydocstyle ignore
"D100", # Missing docstring in public module
"D101", # Missing docstring in public class
"D102", # Missing docstring in public method
"D103", # Missing docstring in public function
"D104", # Missing docstring in public package
"D105", # Missing docstring in magic method
"D106", # Missing docstring in public nested class
"D107", # Missing docstring in `__init__`
"D209", # Multi-line docstring closing quotes should be on a separate line
"D205", # 1 blank line required between summary line and description
"D400", # First line should end with a period
"D401", # First line of docstring should be in imperative mood
# ruff ignore
"RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`
# pylint ignore
"PLW0603", # Using the global statement
"PLW0120", # remove the else and dedent its contents
"PLW2901", # for loop variable overwritten by assignment target
"PLR5501", # Use `elif` instead of `else` then `if`
"UP031", # Use format specifiers instead of percent format
]
[tool.ruff.lint.pycodestyle]
# In order to be able to format for 88 char in ruff format
max-line-length = 120
[tool.ruff.lint.pydocstyle]
convention = "pep257"
[tool.ruff.lint.isort]
force-single-line = true
combine-as-imports = true
force-sort-within-sections = true
order-by-type = false
lines-after-imports = 2
[tool.ruff.lint.per-file-ignores]
"src/xdist/_version.py" = ["I001"]
[tool.mypy]
mypy_path = ["src"]
files = ["src", "testing"]
strict = true
warn_unreachable = true
[[tool.mypy.overrides]]
module = ["xdist._version"]
ignore_missing_imports = true
[tool.towncrier] [tool.towncrier]
package = "xdist" package = "xdist"

View File

@@ -1,80 +0,0 @@
[metadata]
name = pytest-xdist
description = pytest xdist plugin for distributed testing and loop-on-failing modes
long_description = file: README.rst
license = MIT
author = holger krekel and contributors
author_email = pytest-dev@python.org,holger@merlinux.eu
url = https://github.com/pytest-dev/pytest-xdist
platforms =
linux
osx
win32
classifiers =
Development Status :: 5 - Production/Stable
Framework :: Pytest
Intended Audience :: Developers
License :: OSI Approved :: MIT License
Operating System :: POSIX
Operating System :: Microsoft :: Windows
Operating System :: MacOS :: MacOS X
Topic :: Software Development :: Testing
Topic :: Software Development :: Quality Assurance
Topic :: Utilities
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
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]
packages = find:
package_dir = =src
zip_safe = False
python_requires = >=3.6
install_requires =
execnet>=1.1
pytest>=6.2.0
setup_requires = # left empty, enforce using isolated build system
[options.packages.find]
where = src
[options.entry_points]
pytest11 =
xdist = xdist.plugin
xdist.looponfail = xdist.looponfail
[options.extras_require]
testing =
filelock
psutil = psutil>=3.0
setproctitle = setproctitle
[flake8]
max-line-length = 100
[mypy]
mypy_path = src
# TODO: Enable this & fix errors.
# check_untyped_defs = True
disallow_any_generics = True
ignore_missing_imports = True
no_implicit_optional = True
show_error_codes = True
strict_equality = True
warn_redundant_casts = True
warn_return_any = True
warn_unreachable = True
warn_unused_configs = True
# TODO: Enable this & fix errors.
# no_implicit_reexport = True

View File

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

View File

@@ -1,15 +1,16 @@
from xdist.plugin import ( from xdist.plugin import get_xdist_worker_id
is_xdist_worker, from xdist.plugin import is_xdist_controller
is_xdist_master, from xdist.plugin import is_xdist_master
get_xdist_worker_id, from xdist.plugin import is_xdist_worker
is_xdist_controller,
)
from xdist._version import version as __version__ __version__ = "3.7.1.dev24+g2b4372bd6"
__all__ = [ __all__ = [
"__version__", "__version__",
"is_xdist_worker",
"is_xdist_master",
"is_xdist_controller",
"get_xdist_worker_id", "get_xdist_worker_id",
"is_xdist_controller",
"is_xdist_master",
"is_xdist_worker",
] ]

View File

@@ -1,7 +1,8 @@
import os from collections.abc import Iterator
from itertools import chain from itertools import chain
import os
from pathlib import Path from pathlib import Path
from typing import Callable, Iterator from typing import Callable
def visit_path( def visit_path(

View File

@@ -1,17 +1,28 @@
from __future__ import annotations
from collections.abc import Sequence
from enum import auto
from enum import Enum
from queue import Empty
from queue import Queue
import sys
from typing import Any
import warnings
import execnet
import pytest import pytest
from xdist.remote import Producer from xdist.remote import Producer
from xdist.remote import WorkerInfo
from xdist.scheduler import EachScheduling
from xdist.scheduler import LoadFileScheduling
from xdist.scheduler import LoadGroupScheduling
from xdist.scheduler import LoadScheduling
from xdist.scheduler import LoadScopeScheduling
from xdist.scheduler import Scheduling
from xdist.scheduler import WorkStealingScheduling
from xdist.workermanage import NodeManager from xdist.workermanage import NodeManager
from xdist.scheduler import ( from xdist.workermanage import WorkerController
EachScheduling,
LoadScheduling,
LoadScopeScheduling,
LoadFileScheduling,
LoadGroupScheduling,
)
from queue import Empty, Queue
class Interrupted(KeyboardInterrupt): class Interrupted(KeyboardInterrupt):
@@ -19,7 +30,7 @@ class Interrupted(KeyboardInterrupt):
class DSession: class DSession:
"""A pytest plugin which runs a distributed test session """A pytest plugin which runs a distributed test session.
At the beginning of the test session this creates a NodeManager At the beginning of the test session this creates a NodeManager
instance which creates and starts all nodes. Nodes then emit instance which creates and starts all nodes. Nodes then emit
@@ -32,42 +43,44 @@ class DSession:
it will wait for instructions. it will wait for instructions.
""" """
def __init__(self, config): shouldstop: bool | str
def __init__(self, config: pytest.Config) -> None:
self.config = config self.config = config
self.log = Producer("dsession", enabled=config.option.debug) self.log = Producer("dsession", enabled=config.option.debug)
self.nodemanager = None self.nodemanager: NodeManager | None = None
self.sched = None self.sched: Scheduling | None = None
self.shuttingdown = False self.shuttingdown = False
self.countfailures = 0 self.countfailures = 0
self.maxfail = config.getvalue("maxfail") self.maxfail: int = config.getvalue("maxfail")
self.queue = Queue() self.queue: Queue[tuple[str, dict[str, Any]]] = Queue()
self._session = None self._session: pytest.Session | None = None
self._failed_collection_errors = {} self._failed_collection_errors: dict[object, bool] = {}
self._active_nodes = set() self._active_nodes: set[WorkerController] = set()
self._failed_nodes_count = 0 self._failed_nodes_count = 0
self._max_worker_restart = get_default_max_worker_restart(self.config) self._max_worker_restart = get_default_max_worker_restart(self.config)
# summary message to print at the end of the session # summary message to print at the end of the session
self._summary_report = None self._summary_report: str | None = None
self.terminal = config.pluginmanager.getplugin("terminalreporter") self.terminal = config.pluginmanager.getplugin("terminalreporter")
if self.terminal: if self.terminal:
self.trdist = TerminalDistReporter(config) self.trdist = TerminalDistReporter(config)
config.pluginmanager.register(self.trdist, "terminaldistreporter") config.pluginmanager.register(self.trdist, "terminaldistreporter")
@property @property
def session_finished(self): def session_finished(self) -> bool:
"""Return True if the distributed session has finished """Return True if the distributed session has finished.
This means all nodes have executed all test items. This is This means all nodes have executed all test items. This is
used by pytest_runtestloop to break out of its loop. used by pytest_runtestloop to break out of its loop.
""" """
return bool(self.shuttingdown and not self._active_nodes) return bool(self.shuttingdown and not self._active_nodes)
def report_line(self, line): def report_line(self, line: str) -> None:
if self.terminal and self.config.option.verbose >= 0: if self.terminal and self.config.option.verbose >= 0:
self.terminal.write_line(line) self.terminal.write_line(line)
@pytest.hookimpl(trylast=True) @pytest.hookimpl(trylast=True)
def pytest_sessionstart(self, session): def pytest_sessionstart(self, session: pytest.Session) -> None:
"""Creates and starts the nodes. """Creates and starts the nodes.
The nodes are setup to put their events onto self.queue. As The nodes are setup to put their events onto self.queue. As
@@ -79,7 +92,7 @@ class DSession:
self._session = session self._session = session
@pytest.hookimpl @pytest.hookimpl
def pytest_sessionfinish(self, session): def pytest_sessionfinish(self) -> None:
"""Shutdown all nodes.""" """Shutdown all nodes."""
nm = getattr(self, "nodemanager", None) # if not fully initialized nm = getattr(self, "nodemanager", None) # if not fully initialized
if nm is not None: if nm is not None:
@@ -87,38 +100,50 @@ class DSession:
self._session = None self._session = None
@pytest.hookimpl @pytest.hookimpl
def pytest_collection(self): def pytest_collection(self) -> bool:
# prohibit collection of test items in controller process # prohibit collection of test items in controller process
return True return True
@pytest.hookimpl(trylast=True) @pytest.hookimpl(trylast=True)
def pytest_xdist_make_scheduler(self, config, log): def pytest_xdist_make_scheduler(
self,
config: pytest.Config,
log: Producer,
) -> Scheduling | None:
dist = config.getvalue("dist") dist = config.getvalue("dist")
schedulers = { if dist == "each":
"each": EachScheduling, return EachScheduling(config, log)
"load": LoadScheduling, if dist == "load":
"loadscope": LoadScopeScheduling, return LoadScheduling(config, log)
"loadfile": LoadFileScheduling, if dist == "loadscope":
"loadgroup": LoadGroupScheduling, return LoadScopeScheduling(config, log)
} if dist == "loadfile":
return schedulers[dist](config, log) return LoadFileScheduling(config, log)
if dist == "loadgroup":
return LoadGroupScheduling(config, log)
if dist == "worksteal":
return WorkStealingScheduling(config, log)
return None
@pytest.hookimpl @pytest.hookimpl
def pytest_runtestloop(self): def pytest_runtestloop(self) -> bool:
self.sched = self.config.hook.pytest_xdist_make_scheduler( self.sched = self.config.hook.pytest_xdist_make_scheduler(
config=self.config, log=self.log config=self.config, log=self.log
) )
assert self.sched is not None assert self.sched is not None
self.shouldstop = False self.shouldstop = False
pending_exception = None
while not self.session_finished: while not self.session_finished:
self.loop_once() self.loop_once()
if self.shouldstop: if self.shouldstop:
self.triggershutdown() self.triggershutdown()
raise Interrupted(str(self.shouldstop)) pending_exception = Interrupted(str(self.shouldstop))
if pending_exception:
raise pending_exception
return True return True
def loop_once(self): def loop_once(self) -> None:
"""Process one callback from one of the workers.""" """Process one callback from one of the workers."""
while 1: while 1:
if not self._active_nodes: if not self._active_nodes:
@@ -136,6 +161,7 @@ class DSession:
call = getattr(self, method) call = getattr(self, method)
self.log("calling method", method, kwargs) self.log("calling method", method, kwargs)
call(**kwargs) call(**kwargs)
assert self.sched is not None
if self.sched.tests_finished: if self.sched.tests_finished:
self.triggershutdown() self.triggershutdown()
@@ -143,7 +169,11 @@ class DSession:
# callbacks for processing events from workers # callbacks for processing events from workers
# #
def worker_workerready(self, node, workerinfo): def worker_workerready(
self,
node: WorkerController,
workerinfo: WorkerInfo,
) -> None:
"""Emitted when a node first starts up. """Emitted when a node first starts up.
This adds the node to the scheduler, nodes continue with This adds the node to the scheduler, nodes continue with
@@ -157,9 +187,10 @@ class DSession:
if self.shuttingdown: if self.shuttingdown:
node.shutdown() node.shutdown()
else: else:
assert self.sched is not None
self.sched.add_node(node) self.sched.add_node(node)
def worker_workerfinished(self, node): def worker_workerfinished(self, node: WorkerController) -> None:
"""Emitted when node executes its pytest_sessionfinish hook. """Emitted when node executes its pytest_sessionfinish hook.
Removes the node from the scheduler. Removes the node from the scheduler.
@@ -169,15 +200,26 @@ 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
shouldfail = node.workeroutput["shouldfail"]
shouldstop = node.workeroutput["shouldstop"]
for shouldx in [shouldfail, shouldstop]:
if shouldx:
if not self.shouldstop:
self.shouldstop = shouldx
break
else:
assert self.sched is not None
if node in self.sched.nodes: if node in self.sched.nodes:
crashitem = self.sched.remove_node(node) crashitem = self.sched.remove_node(node)
assert not crashitem, (crashitem, node) assert not crashitem, (crashitem, node)
self._active_nodes.remove(node) self._active_nodes.remove(node)
def worker_internal_error(self, node, formatted_error): def worker_internal_error(
self, node: WorkerController, formatted_error: str
) -> None:
""" """
pytest_internalerror() was called on the worker. pytest_internalerror() was called on the worker.
@@ -189,15 +231,14 @@ class DSession:
try: try:
assert False, formatted_error assert False, formatted_error
except AssertionError: except AssertionError:
from _pytest._code import ExceptionInfo excinfo = pytest.ExceptionInfo.from_current()
excinfo = ExceptionInfo.from_current()
excrepr = excinfo.getrepr() excrepr = excinfo.getrepr()
self.config.hook.pytest_internalerror(excrepr=excrepr, excinfo=excinfo) self.config.hook.pytest_internalerror(excrepr=excrepr, excinfo=excinfo)
def worker_errordown(self, node, error): def worker_errordown(self, node: WorkerController, error: object | None) -> None:
"""Emitted by the WorkerController when a node dies.""" """Emitted by the WorkerController when a node dies."""
self.config.hook.pytest_testnodedown(node=node, error=error) self.config.hook.pytest_testnodedown(node=node, error=error)
assert self.sched is not None
try: try:
crashitem = self.sched.remove_node(node) crashitem = self.sched.remove_node(node)
except KeyError: except KeyError:
@@ -213,11 +254,9 @@ class DSession:
) )
if maximum_reached: if maximum_reached:
if self._max_worker_restart == 0: if self._max_worker_restart == 0:
msg = "worker {} crashed and worker restarting disabled".format( msg = f"worker {node.gateway.id} crashed and worker restarting disabled"
node.gateway.id
)
else: else:
msg = "maximum crashed workers reached: %d" % self._max_worker_restart msg = f"maximum crashed workers reached: {self._max_worker_restart}"
self._summary_report = msg self._summary_report = msg
self.report_line("\n" + msg) self.report_line("\n" + msg)
self.triggershutdown() self.triggershutdown()
@@ -228,12 +267,14 @@ class DSession:
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: Any) -> None:
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(
"""worker has finished test collection. self, node: WorkerController, ids: Sequence[str]
) -> None:
"""Worker has finished test collection.
This adds the collection for this node to the scheduler. If This adds the collection for this node to the scheduler. If
the scheduler indicates collection is finished (i.e. all the scheduler indicates collection is finished (i.e. all
@@ -246,43 +287,78 @@ class DSession:
self.config.hook.pytest_xdist_node_collection_finished(node=node, ids=ids) self.config.hook.pytest_xdist_node_collection_finished(node=node, ids=ids)
# tell session which items were effectively collected otherwise # tell session which items were effectively collected otherwise
# the controller node will finish the session with EXIT_NOTESTSCOLLECTED # the controller node will finish the session with EXIT_NOTESTSCOLLECTED
assert self._session is not None
self._session.testscollected = len(ids) self._session.testscollected = len(ids)
assert self.sched is not None
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()
def worker_logstart(self, node, nodeid, location): def worker_logstart(
self,
node: WorkerController,
nodeid: str,
location: tuple[str, int | None, str],
) -> None:
"""Emitted when a node calls the pytest_runtest_logstart hook.""" """Emitted when a node calls the pytest_runtest_logstart hook."""
self.config.hook.pytest_runtest_logstart(nodeid=nodeid, location=location) self.config.hook.pytest_runtest_logstart(nodeid=nodeid, location=location)
def worker_logfinish(self, node, nodeid, location): def worker_logfinish(
self,
node: WorkerController,
nodeid: str,
location: tuple[str, int | None, str],
) -> None:
"""Emitted when a node calls the pytest_runtest_logfinish hook.""" """Emitted when a node calls the pytest_runtest_logfinish hook."""
self.config.hook.pytest_runtest_logfinish(nodeid=nodeid, location=location) self.config.hook.pytest_runtest_logfinish(nodeid=nodeid, location=location)
def worker_testreport(self, node, rep): def worker_testreport(self, node: WorkerController, rep: pytest.TestReport) -> None:
"""Emitted when a node calls the pytest_runtest_logreport hook.""" """Emitted when a node calls the pytest_runtest_logreport hook."""
rep.node = node rep.node = node # type: ignore[attr-defined]
self.config.hook.pytest_runtest_logreport(report=rep) self.config.hook.pytest_runtest_logreport(report=rep)
self._handlefailures(rep) self._handlefailures(rep)
def worker_runtest_protocol_complete(self, node, item_index, duration): def worker_runtest_protocol_complete(
self, node: WorkerController, item_index: int, duration: float
) -> None:
""" """
Emitted when a node fires the 'runtest_protocol_complete' event, Emitted when a node fires the 'runtest_protocol_complete' event,
signalling that a test has completed the runtestprotocol and should be signalling that a test has completed the runtestprotocol and should be
removed from the pending list in the scheduler. removed from the pending list in the scheduler.
""" """
assert self.sched is not None
self.sched.mark_test_complete(node, item_index, duration) self.sched.mark_test_complete(node, item_index, duration)
def worker_collectreport(self, node, rep): def worker_unscheduled(
self, node: WorkerController, indices: Sequence[int]
) -> None:
"""
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.
"""
assert self.sched is not None
self.sched.remove_pending_tests_from_node(node, indices)
def worker_collectreport(
self,
node: WorkerController,
rep: pytest.CollectReport | pytest.TestReport,
) -> None:
"""Emitted when a node calls the pytest_collectreport hook. """Emitted when a node calls the pytest_collectreport hook.
Because we only need the report when there's a failure/skip, as optimization Because we only need the report when there's a failure/skip, as optimization
@@ -291,21 +367,20 @@ class DSession:
assert not rep.passed assert not rep.passed
self._failed_worker_collectreport(node, rep) self._failed_worker_collectreport(node, rep)
def worker_warning_captured(self, warning_message, when, item): def worker_warning_recorded(
"""Emitted when a node calls the pytest_warning_captured hook (deprecated in 6.0).""" self,
# This hook as been removed in pytest 7.1, and we can remove support once we only warning_message: warnings.WarningMessage,
# support pytest >=7.1. when: str,
kwargs = dict(warning_message=warning_message, when=when, item=item) nodeid: str,
self.config.hook.pytest_warning_captured.call_historic(kwargs=kwargs) location: tuple[str, int, str] | None,
) -> None:
def worker_warning_recorded(self, warning_message, when, nodeid, location):
"""Emitted when a node calls the pytest_warning_recorded hook.""" """Emitted when a node calls the pytest_warning_recorded hook."""
kwargs = dict( kwargs = dict(
warning_message=warning_message, when=when, nodeid=nodeid, location=location warning_message=warning_message, when=when, nodeid=nodeid, location=location
) )
self.config.hook.pytest_warning_recorded.call_historic(kwargs=kwargs) self.config.hook.pytest_warning_recorded.call_historic(kwargs=kwargs)
def _clone_node(self, node): def _clone_node(self, node: WorkerController) -> WorkerController:
"""Return new node based on an existing one. """Return new node based on an existing one.
This is normally for when a node dies, this will copy the spec This is normally for when a node dies, this will copy the spec
@@ -315,12 +390,17 @@ class DSession:
""" """
spec = node.gateway.spec spec = node.gateway.spec
spec.id = None spec.id = None
assert self.nodemanager is not None
self.nodemanager.group.allocate_id(spec) self.nodemanager.group.allocate_id(spec)
node = self.nodemanager.setup_node(spec, self.queue.put) clone = self.nodemanager.setup_node(spec, self.queue.put)
self._active_nodes.add(node) self._active_nodes.add(clone)
return node return clone
def _failed_worker_collectreport(self, node, rep): def _failed_worker_collectreport(
self,
node: WorkerController,
rep: pytest.CollectReport | pytest.TestReport,
) -> None:
# Check we haven't already seen this report (from # Check we haven't already seen this report (from
# another worker). # another worker).
if rep.longrepr not in self._failed_collection_errors: if rep.longrepr not in self._failed_collection_errors:
@@ -328,28 +408,41 @@ class DSession:
self.config.hook.pytest_collectreport(report=rep) self.config.hook.pytest_collectreport(report=rep)
self._handlefailures(rep) self._handlefailures(rep)
def _handlefailures(self, rep): def _handlefailures(
self,
rep: pytest.CollectReport | pytest.TestReport,
) -> None:
if rep.failed: if rep.failed:
self.countfailures += 1 self.countfailures += 1
if self.maxfail and self.countfailures >= self.maxfail: if (
self.shouldstop = "stopping after %d failures" % (self.countfailures) self.maxfail
and self.countfailures >= self.maxfail
and not self.shouldstop
):
self.shouldstop = f"stopping after {self.countfailures} failures"
def triggershutdown(self): def triggershutdown(self) -> None:
if not self.shuttingdown:
self.log("triggering shutdown") self.log("triggering shutdown")
self.shuttingdown = True self.shuttingdown = True
assert self.sched is not None
for node in self.sched.nodes: for node in self.sched.nodes:
node.shutdown() node.shutdown()
def handle_crashitem(self, nodeid, worker): def handle_crashitem(self, nodeid: str, worker: WorkerController) -> None:
# XXX get more reporting info by recording pytest_runtest_logstart? # XXX get more reporting info by recording pytest_runtest_logstart?
# XXX count no of failures and retry N times # XXX count no of failures and retry N times
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 = pytest.TestReport(
nodeid, (fspath, None, fspath), (), "failed", msg, "???" nodeid=nodeid,
location=(fspath, None, fspath),
keywords={},
outcome="failed",
longrepr=msg,
when="???", # type: ignore[arg-type]
) )
rep.node = worker rep.node = worker # type: ignore[attr-defined]
self.config.hook.pytest_handlecrashitem( self.config.hook.pytest_handlecrashitem(
crashitem=nodeid, crashitem=nodeid,
@@ -359,36 +452,58 @@ 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: pytest.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[object, 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: execnet.XSpec,
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: str, newline: bool = False) -> None:
pline = line + " " * max(self._lastlen - len(line), 0) pline = line + " " * max(self._lastlen - len(line), 0)
if newline: if newline:
self._lastlen = 0 self._lastlen = 0
@@ -398,51 +513,89 @@ 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: Sequence[execnet.XSpec]) -> 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: execnet.Gateway) -> None:
if self.config.option.verbose > 0: if self.config.option.verbose > 0:
rinfo = gateway._rinfo() rinfo = gateway._rinfo()
version = "%s.%s.%s" % rinfo.version_info[:3] different_interpreter = rinfo.executable != sys.executable
if different_interpreter:
version = "{}.{}.{}".format(*rinfo.version_info[:3])
self.rewrite( self.rewrite(
"[%s] %s Python %s cwd: %s" f"[{gateway.id}] {rinfo.platform} Python {version} cwd: {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: WorkerController) -> None:
if self.config.option.verbose > 0: if self.config.option.verbose > 0:
d = node.workerinfo d = node.workerinfo
infoline = "[{}] Python {}".format( different_interpreter = d.get("executable") != sys.executable
d["id"], d["version"].replace("\n", " -- ") if different_interpreter:
version = d["version"].replace("\n", " -- ")
self.rewrite(f"[{d['id']}] Python {version}", newline=True)
self.setstatus(
node.gateway.spec, WorkerStatus.ReadyForCollection, tests_collected=0
) )
self.rewrite(infoline, newline=True)
self.setstatus(node.gateway.spec, "ok")
@pytest.hookimpl @pytest.hookimpl
def pytest_testnodedown(self, node, error): def pytest_testnodedown(self, node: WorkerController, error: object) -> 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: pytest.Config) -> int | None:
"""gets the default value of --max-worker-restart option if it is not provided. """Gets the default value of --max-worker-restart option if it is not provided.
Use a reasonable default to avoid workers from restarting endlessly due to crashing collections (#226). Use a reasonable default to avoid workers from restarting endlessly due to crashing collections (#226).
""" """
result = config.option.maxworkerrestart result_str: str | None = config.option.maxworkerrestart
if result is not None: if result_str is not None:
result = int(result) result = int(result_str)
elif config.option.numprocesses: elif config.option.numprocesses:
# 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
else:
result = None
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

@@ -1,26 +1,30 @@
""" """
Implement -f aka looponfailing for pytest. Implement -f aka looponfailing for pytest.
NOTE that we try to avoid loading and depending on application modules NOTE that we try to avoid loading and depending on application modules
within the controlling process (the one that starts repeatedly test within the controlling process (the one that starts repeatedly test
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.
""" """
from __future__ import annotations
from collections.abc import Sequence
import os import os
from pathlib import Path from pathlib import Path
from typing import Dict, Sequence
import pytest
import sys import sys
import time import time
import execnet from typing import Any
from _pytest._io import TerminalWriter from _pytest._io import TerminalWriter
import execnet
import pytest
from xdist._path import visit_path from xdist._path import visit_path
@pytest.hookimpl @pytest.hookimpl
def pytest_addoption(parser): def pytest_addoption(parser: pytest.Parser) -> None:
group = parser.getgroup("xdist", "distributed and subprocess testing") group = parser.getgroup("xdist", "distributed and subprocess testing")
group._addoption( group._addoption(
"-f", "-f",
@@ -28,23 +32,23 @@ def pytest_addoption(parser):
action="store_true", action="store_true",
dest="looponfail", dest="looponfail",
default=False, default=False,
help="run tests in subprocess, wait for modified files " help="Run tests in subprocess: wait for files to be modified, then "
"and re-run failing test set until all pass.", "re-run failing test set until all pass.",
) )
@pytest.hookimpl @pytest.hookimpl
def pytest_cmdline_main(config): def pytest_cmdline_main(config: pytest.Config) -> int | None:
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:
raise pytest.UsageError("--pdb is incompatible with --looponfail.") raise pytest.UsageError("--pdb is incompatible with --looponfail.")
looponfail_main(config) looponfail_main(config)
return 2 # looponfail only can get stop with ctrl-C anyway return 2 # looponfail only can get stop with ctrl-C anyway
return None
def looponfail_main(config: "pytest.Config") -> None: def looponfail_main(config: pytest.Config) -> None:
remotecontrol = RemoteControl(config) remotecontrol = RemoteControl(config)
config_roots = config.getini("looponfailroots") config_roots = config.getini("looponfailroots")
if not config_roots: if not config_roots:
@@ -66,21 +70,21 @@ def looponfail_main(config: "pytest.Config") -> None:
class RemoteControl: class RemoteControl:
def __init__(self, config): gateway: execnet.Gateway
self.config = config
self.failures = []
def trace(self, *args): def __init__(self, config: pytest.Config) -> None:
self.config = config
self.failures: list[str] = []
def trace(self, *args: object) -> None:
if self.config.option.debug: if self.config.option.debug:
msg = " ".join(str(x) for x in args) msg = " ".join(str(x) for x in args)
print("RemoteControl:", msg) print("RemoteControl:", msg)
def initgateway(self): def initgateway(self) -> execnet.Gateway:
return execnet.makegateway("popen") return execnet.makegateway("execmodel=main_thread_only//popen")
def setup(self, out=None): def setup(self) -> None:
if out is None:
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")
@@ -90,15 +94,17 @@ class RemoteControl:
args=self.config.args, args=self.config.args,
option_dict=vars(self.config.option), option_dict=vars(self.config.option),
) )
remote_outchannel = channel.receive() remote_outchannel: execnet.Channel = channel.receive()
def write(s): out = TerminalWriter()
def write(s: str) -> None:
out._file.write(s) out._file.write(s)
out._file.flush() out._file.flush()
remote_outchannel.setcallback(write) remote_outchannel.setcallback(write)
def ensure_teardown(self): def ensure_teardown(self) -> None:
if hasattr(self, "channel"): if hasattr(self, "channel"):
if not self.channel.isclosed(): if not self.channel.isclosed():
self.trace("closing", self.channel) self.trace("closing", self.channel)
@@ -109,12 +115,12 @@ class RemoteControl:
self.gateway.exit() self.gateway.exit()
del self.gateway del self.gateway
def runsession(self): def runsession(self) -> tuple[list[str], list[str], bool]:
try: try:
self.trace("sending", self.failures) self.trace("sending", self.failures)
self.channel.send(self.failures) self.channel.send(self.failures)
try: try:
return self.channel.receive() return self.channel.receive() # type: ignore[no-any-return]
except self.channel.RemoteError: except self.channel.RemoteError:
e = sys.exc_info()[1] e = sys.exc_info()[1]
self.trace("ERROR", e) self.trace("ERROR", e)
@@ -122,7 +128,7 @@ class RemoteControl:
finally: finally:
self.ensure_teardown() self.ensure_teardown()
def loop_once(self): def loop_once(self) -> None:
self.setup() self.setup()
self.wasfailing = self.failures and len(self.failures) self.wasfailing = self.failures and len(self.failures)
result = self.runsession() result = self.runsession()
@@ -137,7 +143,9 @@ class RemoteControl:
self.failures = uniq_failures self.failures = uniq_failures
def repr_pytest_looponfailinfo(failreports, rootdirs): def repr_pytest_looponfailinfo(
failreports: Sequence[str], rootdirs: Sequence[Path]
) -> None:
tr = TerminalWriter() tr = TerminalWriter()
if failreports: if failreports:
tr.sep("#", "LOOPONFAILING", bold=True) tr.sep("#", "LOOPONFAILING", bold=True)
@@ -146,10 +154,14 @@ 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: "execnet.Channel", # noqa: UP037
args: list[str],
option_dict: dict[str, "Any"], # noqa: UP037
) -> None:
import os import os
import sys import sys
@@ -160,13 +172,14 @@ def init_worker_session(channel, args, option_dict):
newpaths = [] newpaths = []
for p in sys.path: for p in sys.path:
if p: if p:
if not os.path.isabs(p): # Ignore path placeholders created for editable installs
if not os.path.isabs(p) and not p.endswith(".__path_hook__"):
p = os.path.abspath(p) p = os.path.abspath(p)
newpaths.append(p) newpaths.append(p)
sys.path[:] = newpaths sys.path[:] = newpaths
# fullwidth, hasmarkup = channel.receive() # fullwidth, hasmarkup = channel.receive()
from _pytest.config import Config from pytest import Config
config = Config.fromdictargs(option_dict, list(args)) config = Config.fromdictargs(option_dict, list(args))
config.args = args config.args = args
@@ -176,21 +189,21 @@ def init_worker_session(channel, args, option_dict):
class WorkerFailSession: class WorkerFailSession:
def __init__(self, config, channel): def __init__(self, config: pytest.Config, channel: execnet.Channel) -> None:
self.config = config self.config = config
self.channel = channel self.channel = channel
self.recorded_failures = [] self.recorded_failures: list[pytest.CollectReport | pytest.TestReport] = []
self.collection_failed = False self.collection_failed = False
config.pluginmanager.register(self) config.pluginmanager.register(self)
config.option.looponfail = False config.option.looponfail = False
config.option.usepdb = False config.option.usepdb = False
def DEBUG(self, *args): def DEBUG(self, *args: object) -> None:
if self.config.option.debug: if self.config.option.debug:
print(" ".join(map(str, args))) print(" ".join(map(str, args)))
@pytest.hookimpl @pytest.hookimpl
def pytest_collection(self, session): def pytest_collection(self, session: pytest.Session) -> bool:
self.session = session self.session = session
self.trails = self.current_command self.trails = self.current_command
hook = self.session.ihook hook = self.session.ihook
@@ -205,17 +218,17 @@ class WorkerFailSession:
return True return True
@pytest.hookimpl @pytest.hookimpl
def pytest_runtest_logreport(self, report): def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
if report.failed: if report.failed:
self.recorded_failures.append(report) self.recorded_failures.append(report)
@pytest.hookimpl @pytest.hookimpl
def pytest_collectreport(self, report): def pytest_collectreport(self, report: pytest.CollectReport) -> None:
if report.failed: if report.failed:
self.recorded_failures.append(report) self.recorded_failures.append(report)
self.collection_failed = True self.collection_failed = True
def main(self): def main(self) -> None:
self.DEBUG("WORKER: received configuration, waiting for command trails") self.DEBUG("WORKER: received configuration, waiting for command trails")
try: try:
command = self.channel.receive() command = self.channel.receive()
@@ -230,13 +243,14 @@ class WorkerFailSession:
loc = rep.longrepr loc = rep.longrepr
loc = str(getattr(loc, "reprcrash", loc)) loc = str(getattr(loc, "reprcrash", loc))
failreports.append(loc) failreports.append(loc)
self.channel.send((trails, failreports, self.collection_failed)) result = (trails, failreports, self.collection_failed)
self.channel.send(result)
class StatRecorder: class StatRecorder:
def __init__(self, rootdirlist: Sequence[Path]) -> None: def __init__(self, rootdirlist: Sequence[Path]) -> None:
self.rootdirlist = rootdirlist self.rootdirlist = rootdirlist
self.statcache: Dict[Path, os.stat_result] = {} self.statcache: dict[Path, os.stat_result] = {}
self.check() # snapshot state self.check() # snapshot state
def fil(self, p: Path) -> bool: def fil(self, p: Path) -> bool:
@@ -245,16 +259,16 @@ class StatRecorder:
def rec(self, p: Path) -> bool: def rec(self, p: Path) -> bool:
return not p.name.startswith(".") and p.exists() return not p.name.startswith(".") and p.exists()
def waitonchange(self, checkinterval=1.0): def waitonchange(self, checkinterval: float = 1.0) -> None:
while 1: while 1:
changed = self.check() changed = self.check()
if changed: if changed:
return return
time.sleep(checkinterval) time.sleep(checkinterval)
def check(self, removepycfiles: bool = True) -> bool: # noqa, too complex def check(self, removepycfiles: bool = True) -> bool:
changed = False changed = False
newstat: Dict[Path, os.stat_result] = {} newstat: dict[Path, os.stat_result] = {}
for rootdir in self.rootdirlist: for rootdir in self.rootdirlist:
for path in visit_path(rootdir, filter=self.fil, recurse=self.rec): for path in visit_path(rootdir, filter=self.fil, recurse=self.rec):
oldstat = self.statcache.pop(path, None) oldstat = self.statcache.pop(path, None)

View File

@@ -11,75 +11,109 @@ must be taken in plugins in case ``xdist`` is not installed. Please see:
http://pytest.org/en/latest/writing_plugins.html#optionally-using-hooks-from-3rd-party-plugins http://pytest.org/en/latest/writing_plugins.html#optionally-using-hooks-from-3rd-party-plugins
""" """
from __future__ import annotations
from collections.abc import Sequence
import os
from typing import Any
from typing import TYPE_CHECKING
import execnet
import pytest import pytest
@pytest.hookspec() if TYPE_CHECKING:
def pytest_xdist_setupnodes(config, specs): from xdist.remote import Producer
"""called before any remote node is set up.""" from xdist.scheduler.protocol import Scheduling
from xdist.workermanage import WorkerController
@pytest.hookspec() @pytest.hookspec()
def pytest_xdist_newgateway(gateway): def pytest_xdist_setupnodes(
"""called on new raw gateway creation.""" config: pytest.Config, specs: Sequence[execnet.XSpec]
) -> None:
"""Called before any remote node is set up."""
@pytest.hookspec()
def pytest_xdist_newgateway(gateway: execnet.Gateway) -> None:
"""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" warn_on_impl=DeprecationWarning(
"rsync feature is deprecated and will be removed in pytest-xdist 4.0"
)
) )
def pytest_xdist_rsyncstart(source, gateways): def pytest_xdist_rsyncstart(
"""called before rsyncing a directory to remote gateways takes place.""" source: str | os.PathLike[str],
gateways: Sequence[execnet.Gateway],
) -> None:
"""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" warn_on_impl=DeprecationWarning(
"rsync feature is deprecated and will be removed in pytest-xdist 4.0"
)
) )
def pytest_xdist_rsyncfinish(source, gateways): def pytest_xdist_rsyncfinish(
"""called after rsyncing a directory to remote gateways takes place.""" source: str | os.PathLike[str],
gateways: Sequence[execnet.Gateway],
) -> None:
"""Called after rsyncing a directory to remote gateways takes place."""
@pytest.hookspec(firstresult=True) @pytest.hookspec(firstresult=True)
def pytest_xdist_getremotemodule(): def pytest_xdist_getremotemodule() -> Any:
"""called when creating remote node""" """Called when creating remote node."""
@pytest.hookspec() @pytest.hookspec()
def pytest_configure_node(node): def pytest_configure_node(node: WorkerController) -> None:
"""configure node information before it gets instantiated.""" """Configure node information before it gets instantiated."""
@pytest.hookspec() @pytest.hookspec()
def pytest_testnodeready(node): def pytest_testnodeready(node: WorkerController) -> None:
"""Test Node is ready to operate.""" """Test Node is ready to operate."""
@pytest.hookspec() @pytest.hookspec()
def pytest_testnodedown(node, error): def pytest_testnodedown(node: WorkerController, error: object | None) -> None:
"""Test Node is down.""" """Test Node is down."""
@pytest.hookspec() @pytest.hookspec()
def pytest_xdist_node_collection_finished(node, ids): def pytest_xdist_node_collection_finished(
"""called by the controller node when a worker node finishes collecting.""" node: WorkerController, ids: Sequence[str]
) -> None:
"""Called by the controller node when a worker node finishes collecting."""
@pytest.hookspec(firstresult=True) @pytest.hookspec(firstresult=True)
def pytest_xdist_make_scheduler(config, log): def pytest_xdist_make_scheduler(
"""return a node scheduler implementation""" config: pytest.Config, log: Producer
) -> Scheduling | None:
"""Return a node scheduler implementation."""
@pytest.hookspec(firstresult=True) @pytest.hookspec(firstresult=True)
def pytest_xdist_auto_num_workers(config): def pytest_xdist_auto_num_workers(config: pytest.Config) -> int:
""" """
Return the number of workers to spawn when ``--numprocesses=auto`` is given in the Return the number of workers to spawn when ``--numprocesses=auto`` is given in the
command-line. command-line.
.. versionadded:: 2.1 .. versionadded:: 2.1
""" """
raise NotImplementedError()
@pytest.hookspec(firstresult=True) @pytest.hookspec(firstresult=True)
def pytest_handlecrashitem(crashitem, report, sched): def pytest_handlecrashitem(
crashitem: str, report: pytest.TestReport, sched: Scheduling
) -> None:
""" """
Handle a crashitem, modifying the report if necessary. Handle a crashitem, modifying the report if necessary.

View File

@@ -1,38 +1,41 @@
from __future__ import annotations
import os import os
import uuid
import sys import sys
from typing import Literal
import uuid
import warnings import warnings
import pytest import pytest
PYTEST_GTE_7 = hasattr(pytest, "version_tuple") and pytest.version_tuple >= (7, 0) # type: ignore[attr-defined]
_sys_path = list(sys.path) # freeze a copy of sys.path at interpreter startup _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: pytest.Config) -> int:
env_var = os.environ.get("PYTEST_XDIST_AUTO_NUM_WORKERS") env_var = os.environ.get("PYTEST_XDIST_AUTO_NUM_WORKERS")
if env_var: if env_var:
try: try:
return int(env_var) return int(env_var)
except ValueError: except ValueError:
warnings.warn("PYTEST_XDIST_AUTO_NUM_WORKERS is not a number: {env_var!r}. Ignoring it.") 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:
pass pass
else: else:
use_logical = config.option.numprocesses == "logical" use_logical: bool = config.option.numprocesses == "logical"
count = psutil.cpu_count(logical=use_logical) or psutil.cpu_count() count = psutil.cpu_count(logical=use_logical) or psutil.cpu_count()
if count: if count:
return count return count
try: try:
from os import sched_getaffinity from os import sched_getaffinity
def cpu_count(): def cpu_count() -> int:
return len(sched_getaffinity(0)) return len(sched_getaffinity(0))
except ImportError: except ImportError:
@@ -40,7 +43,7 @@ def pytest_xdist_auto_num_workers(config):
# workaround https://bitbucket.org/pypy/pypy/issues/2375 # workaround https://bitbucket.org/pypy/pypy/issues/2375
return 2 return 2
try: try:
from os import cpu_count from os import cpu_count # type: ignore[assignment]
except ImportError: except ImportError:
from multiprocessing import cpu_count from multiprocessing import cpu_count
try: try:
@@ -50,15 +53,19 @@ def pytest_xdist_auto_num_workers(config):
return n if n else 1 return n if n else 1
def parse_numprocesses(s): def parse_numprocesses(s: str) -> int | Literal["auto", "logical"]:
if s in ("auto", "logical"): if s in ("auto", "logical"):
return s return s # type: ignore[return-value]
elif s is not None: elif s is not None:
return int(s) return int(s)
@pytest.hookimpl @pytest.hookimpl
def pytest_addoption(parser): def pytest_addoption(parser: pytest.Parser) -> None:
# 'Help' formatting (same rules as pytest's):
# Start with capitalized letters.
# If a single phrase, do not end with period. If more than one phrase, all phrases end with periods.
# Use \n to separate logical lines.
group = parser.getgroup("xdist", "distributed and subprocess testing") group = parser.getgroup("xdist", "distributed and subprocess testing")
group._addoption( group._addoption(
"-n", "-n",
@@ -67,10 +74,11 @@ def pytest_addoption(parser):
metavar="numprocesses", metavar="numprocesses",
action="store", action="store",
type=parse_numprocesses, type=parse_numprocesses,
help="Shortcut for '--dist=load --tx=NUM*popen'. With 'auto', attempt " help="Shortcut for '--dist=load --tx=NUM*popen'.\n"
"to detect physical CPU count. With 'logical', detect logical CPU " "With 'logical', attempt to detect logical CPU count (requires psutil, falls back to 'auto').\n"
"count. If physical CPU count cannot be found, falls back to logical " "With 'auto', attempt to detect physical CPU count. If physical CPU count cannot be determined, "
"count. This will be 0 when used with --pdb.", "falls back to 1.\n"
"Forced to 0 (disabled) when used with --pdb.",
) )
group.addoption( group.addoption(
"--maxprocesses", "--maxprocesses",
@@ -78,34 +86,71 @@ def pytest_addoption(parser):
metavar="maxprocesses", metavar="maxprocesses",
action="store", action="store",
type=int, type=int,
help="limit the maximum number of workers to process the tests when using --numprocesses=auto", help="Limit the maximum number of workers to process the tests when using --numprocesses "
"with 'auto' or 'logical'",
) )
group.addoption( group.addoption(
"--max-worker-restart", "--max-worker-restart",
action="store", action="store",
default=None, default=None,
dest="maxworkerrestart", dest="maxworkerrestart",
help="maximum number of workers that can be restarted " help="Maximum number of workers that can be restarted "
"when crashed (set to zero to disable this feature)", "when crashed (set to zero to disable this feature)",
) )
group.addoption( group.addoption(
"--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=(
"set mode for distributing tests to exec environments.\n\n" "Set mode for distributing tests to exec environments.\n\n"
"each: send each test to all available environments.\n\n" "each: Send each test to all available environments.\n\n"
"load: load balance by sending any pending test to any" "load: Load balance by sending any pending test to any"
" available environment.\n\n" " available environment.\n\n"
"loadscope: load balance by sending pending groups of tests in" "loadscope: Load balance by sending pending groups of tests in"
" the same scope to any available environment.\n\n" " the same scope to any available environment.\n\n"
"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"
"(default) no: run tests inprocess, don't distribute." "worksteal: Split the test suite between available environments,"
" then re-balance when any worker runs out of tests.\n\n"
"(default) no: Run tests inprocess, don't distribute."
),
)
group.addoption(
"--loadscope-reorder",
dest="loadscopereorder",
action="store_true",
default=True,
help=(
"Pytest-xdist will default reorder tests by number of tests per scope "
"when used in conjunction with loadscope.\n"
"This option will enable loadscope reorder which will improve the "
"parallelism of the test suite.\n"
"However, the partial order of tests might not be retained.\n"
),
)
group.addoption(
"--no-loadscope-reorder",
dest="loadscopereorder",
action="store_false",
help=(
"Pytest-xdist will default reorder tests by number of tests per scope "
"when used in conjunction with loadscope.\n"
"This option will disable loadscope reorder, "
"and the partial order of tests can be retained.\n"
"This is useful when pytest-xdist is used together with "
"other plugins that specify tests in a specific order."
), ),
) )
group.addoption( group.addoption(
@@ -115,56 +160,82 @@ def pytest_addoption(parser):
default=[], default=[],
metavar="xspec", metavar="xspec",
help=( help=(
"add a test execution environment. some examples: " "Add a test execution environment. Some examples:\n"
"--tx popen//python=python2.5 --tx socket=192.168.1.102:8888 " "--tx popen//python=python2.5 --tx socket=192.168.1.102:8888\n"
"--tx ssh=user@codespeak.net//chdir=testcache" "--tx ssh=user@codespeak.net//chdir=testcache"
), ),
) )
group.addoption(
"--px",
dest="px",
action="append",
default=[],
metavar="xspec",
help=(
"Add a proxy gateway to pass to test execution environments using `via`. Example:\n"
"--px id=my_proxy//socket=192.168.1.102:8888 --tx 5*popen//via=my_proxy"
),
)
group._addoption( group._addoption(
"-d", "-d",
action="store_true", action="store_true",
dest="distload", dest="distload",
default=False, default=False,
help="load-balance tests. shortcut for '--dist=load'", help="Load-balance tests. Shortcut for '--dist=load'.",
) )
group.addoption( group.addoption(
"--rsyncdir", "--rsyncdir",
action="append", action="append",
default=[], default=[],
metavar="DIR", metavar="DIR",
help="add directory for rsyncing to remote tx nodes.", help="Add directory for rsyncing to remote tx nodes",
) )
group.addoption( group.addoption(
"--rsyncignore", "--rsyncignore",
action="append", action="append",
default=[], default=[],
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( group.addoption(
"--testrunuid", "--testrunuid",
action="store", action="store",
help=( help=(
"provide an identifier shared amongst all workers as the value of " "Provide an identifier shared amongst all workers as the value of "
"the 'testrun_uid' fixture,\n\n," "the 'testrun_uid' fixture.\n"
"if not provided, 'testrun_uid' is filled with a new unique string " "If not provided, 'testrun_uid' is filled with a new unique string "
"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.\n"
"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.\n"
"Larger numbers will allow the scheduler to submit consecutive "
"chunks of tests to workers - allows reusing fixtures.\n"
"Due to implementation reasons, at least 2 tests are scheduled per "
"worker at the start. Only later tests can be scheduled one by one.\n"
"Unlimited if not set."
),
)
parser.addini( parser.addini(
"rsyncdirs", "rsyncdirs",
"list of (relative) paths to be rsynced for remote distributed testing.", "list of (relative) paths to be rsynced for remote distributed testing.",
type="paths" if PYTEST_GTE_7 else "pathlist", type="paths",
) )
parser.addini( parser.addini(
"rsyncignore", "rsyncignore",
"list of (relative) glob-style paths to be ignored for rsyncing.", "list of (relative) glob-style paths to be ignored for rsyncing.",
type="paths" if PYTEST_GTE_7 else "pathlist", type="paths",
) )
parser.addini( parser.addini(
"looponfailroots", "looponfailroots",
type="paths" if PYTEST_GTE_7 else "pathlist", type="paths",
help="directories to check for changes. Default: current directory.", help="directories to check for changes. Default: current directory.",
) )
@@ -175,7 +246,7 @@ def pytest_addoption(parser):
@pytest.hookimpl @pytest.hookimpl
def pytest_addhooks(pluginmanager): def pytest_addhooks(pluginmanager: pytest.PytestPluginManager) -> None:
from xdist import newhooks from xdist import newhooks
pluginmanager.add_hookspecs(newhooks) pluginmanager.add_hookspecs(newhooks)
@@ -187,8 +258,20 @@ def pytest_addhooks(pluginmanager):
@pytest.hookimpl(trylast=True) @pytest.hookimpl(trylast=True)
def pytest_configure(config): def pytest_configure(config: pytest.Config) -> None:
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 _is_distribution_mode(config):
from xdist.dsession import DSession from xdist.dsession import DSession
session = DSession(config) session = DSession(config)
@@ -197,6 +280,7 @@ def pytest_configure(config):
if tr: if tr:
tr.showfspath = False tr.showfspath = False
# Deprecation warnings for deprecated command-line/configuration options.
if config.getoption("looponfail", None) or config.getini("looponfailroots"): if config.getoption("looponfail", None) or config.getini("looponfailroots"):
warning = DeprecationWarning( warning = DeprecationWarning(
"The --looponfail command line argument and looponfailroots config variable are deprecated.\n" "The --looponfail command line argument and looponfailroots config variable are deprecated.\n"
@@ -211,15 +295,17 @@ def pytest_configure(config):
) )
config.issue_config_time_warning(warning, 2) config.issue_config_time_warning(warning, 2)
config_line = (
"xdist_group: specify group for tests should run in same session." def _is_distribution_mode(config: pytest.Config) -> bool:
"in relation to one another. " + "Provided by pytest-xdist." """Whether distribution mode is on."""
) return config.getoption("dist") != "no" and bool(config.getoption("tx"))
config.addinivalue_line("markers", config_line)
@pytest.hookimpl(tryfirst=True) @pytest.hookimpl(tryfirst=True)
def pytest_cmdline_main(config): def pytest_cmdline_main(config: pytest.Config) -> None:
if config.option.distload:
config.option.dist = "load"
usepdb = config.getoption("usepdb", False) # a core option usepdb = config.getoption("usepdb", False) # a core option
if config.option.numprocesses in ("auto", "logical"): if config.option.numprocesses in ("auto", "logical"):
if usepdb: if usepdb:
@@ -236,13 +322,16 @@ def pytest_cmdline_main(config):
if config.option.maxprocesses: if config.option.maxprocesses:
numprocesses = min(numprocesses, config.option.maxprocesses) numprocesses = min(numprocesses, config.option.maxprocesses)
config.option.tx = ["popen"] * numprocesses config.option.tx = ["popen"] * numprocesses
if config.option.distload:
config.option.dist = "load" if config.option.numprocesses == 0:
config.option.dist = "no"
config.option.tx = []
val = config.getvalue val = config.getvalue
if not val("collectonly") and val("dist") != "no" and usepdb: if not val("collectonly") and _is_distribution_mode(config) and usepdb:
raise pytest.UsageError( raise pytest.UsageError(
"--pdb is incompatible with distributing tests; try using -n0 or -nauto." "--pdb is incompatible with distributing tests; try using -n0 or -nauto."
) # noqa: E501 )
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -250,16 +339,20 @@ def pytest_cmdline_main(config):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def is_xdist_worker(request_or_session) -> bool: def is_xdist_worker(
"""Return `True` if this is an xdist worker, `False` otherwise request_or_session: pytest.FixtureRequest | pytest.Session,
) -> bool:
"""Return `True` if this is an xdist worker, `False` otherwise.
:param request_or_session: the `pytest` `request` or `session` object :param request_or_session: the `pytest` `request` or `session` object
""" """
return hasattr(request_or_session.config, "workerinput") return hasattr(request_or_session.config, "workerinput")
def is_xdist_controller(request_or_session) -> bool: def is_xdist_controller(
"""Return `True` if this is the xdist controller, `False` otherwise request_or_session: pytest.FixtureRequest | pytest.Session,
) -> bool:
"""Return `True` if this is the xdist controller, `False` otherwise.
Note: this method also returns `False` when distribution has not been Note: this method also returns `False` when distribution has not been
activated at all. activated at all.
@@ -276,7 +369,9 @@ def is_xdist_controller(request_or_session) -> bool:
is_xdist_master = is_xdist_controller is_xdist_master = is_xdist_controller
def get_xdist_worker_id(request_or_session): def get_xdist_worker_id(
request_or_session: pytest.FixtureRequest | pytest.Session,
) -> str:
"""Return the id of the current worker ('gw0', 'gw1', etc) or 'master' """Return the id of the current worker ('gw0', 'gw1', etc) or 'master'
if running on the controller node. if running on the controller node.
@@ -286,14 +381,15 @@ def get_xdist_worker_id(request_or_session):
:param request_or_session: the `pytest` `request` or `session` object :param request_or_session: the `pytest` `request` or `session` object
""" """
if hasattr(request_or_session.config, "workerinput"): if hasattr(request_or_session.config, "workerinput"):
return request_or_session.config.workerinput["workerid"] workerid: str = request_or_session.config.workerinput["workerid"]
return workerid
else: else:
# TODO: remove "master", ideally for a None # TODO: remove "master", ideally for a None
return "master" return "master"
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def worker_id(request): def worker_id(request: pytest.FixtureRequest) -> str:
"""Return the id of the current worker ('gw0', 'gw1', etc) or 'master' """Return the id of the current worker ('gw0', 'gw1', etc) or 'master'
if running on the master node. if running on the master node.
""" """
@@ -302,9 +398,10 @@ def worker_id(request):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def testrun_uid(request): def testrun_uid(request: pytest.FixtureRequest) -> str:
"""Return the unique id of the current test.""" """Return the unique id of the current test."""
if hasattr(request.config, "workerinput"): if hasattr(request.config, "workerinput"):
return request.config.workerinput["testrunuid"] testrunid: str = request.config.workerinput["testrunuid"]
return testrunid
else: else:
return uuid.uuid4().hex return uuid.uuid4().hex

View File

@@ -1,26 +1,38 @@
""" """
This module is executed in remote subprocesses and helps to This module is executed in remote subprocesses and helps to
control a remote testing session and relay back information. control a remote testing session and relay back information.
It assumes that 'py' is importable and does not have dependencies It assumes that 'py' is importable and does not have dependencies
on the rest of the xdist code. This means that the xdist-plugin on the rest of the xdist code. This means that the xdist-plugin
needs not to be installed in remote environments. needs not to be installed in remote environments.
""" """
import sys from __future__ import annotations
import collections
from collections.abc import Generator
from collections.abc import Iterable
from collections.abc import Sequence
import contextlib
import enum
import os import os
import sys
import time import time
from typing import Any from typing import Any
from typing import Literal
from typing import TypedDict
from typing import Union
import warnings
from _pytest.config import _prepareconfig
import execnet
import pytest import pytest
from execnet.gateway_base import dumps, DumpError
from _pytest.config import _prepareconfig, Config
try: try:
from setproctitle import setproctitle from setproctitle import setproctitle
except ImportError: except ImportError:
def setproctitle(title): def setproctitle(title: str) -> None:
pass pass
@@ -32,7 +44,7 @@ class Producer:
to have the other way around. to have the other way around.
""" """
def __init__(self, name: str, *, enabled: bool = True): def __init__(self, name: str, *, enabled: bool = True) -> None:
self.name = name self.name = name
self.enabled = enabled self.enabled = enabled
@@ -43,11 +55,11 @@ class Producer:
if self.enabled: if self.enabled:
print(f"[{self.name}]", *a, **k, file=sys.stderr) print(f"[{self.name}]", *a, **k, file=sys.stderr)
def __getattr__(self, name: str) -> "Producer": def __getattr__(self, name: str) -> Producer:
return type(self)(name, enabled=self.enabled) return type(self)(name, enabled=self.enabled)
def worker_title(title): def worker_title(title: str) -> None:
try: try:
setproctitle(title) setproctitle(title)
except Exception: except Exception:
@@ -55,81 +67,165 @@ def worker_title(title):
pass pass
class Marker(enum.Enum):
SHUTDOWN = 0
class TestQueue:
"""A simple queue that can be inspected and modified while the lock is held via the ``lock()`` method."""
Item = Union[int, Literal[Marker.SHUTDOWN]]
def __init__(self, execmodel: execnet.gateway_base.ExecModel):
self._items: collections.deque[TestQueue.Item] = collections.deque()
self._lock = execmodel.RLock() # type: ignore[no-untyped-call]
self._has_items_event = execmodel.Event()
def get(self) -> Item:
while True:
with self.lock() as locked_items:
if locked_items:
return locked_items.popleft()
self._has_items_event.wait()
def put(self, item: Item) -> None:
with self.lock() as locked_items:
locked_items.append(item)
def replace(self, iterable: Iterable[Item]) -> None:
with self.lock():
self._items = collections.deque(iterable)
@contextlib.contextmanager
def lock(self) -> Generator[collections.deque[Item]]:
with self._lock:
try:
yield self._items
finally:
if self._items:
self._has_items_event.set()
else:
self._has_items_event.clear()
class WorkerInteractor: class WorkerInteractor:
def __init__(self, config, channel): def __init__(self, config: pytest.Config, channel: execnet.Channel) -> None:
self.config = config self.config = config
self.workerid = config.workerinput.get("workerid", "?") workerinput: dict[str, Any] = config.workerinput # type: ignore[attr-defined]
self.testrunuid = config.workerinput["testrunuid"] self.workerid = workerinput.get("workerid", "?")
self.testrunuid = workerinput["testrunuid"]
self.log = Producer(f"worker-{self.workerid}", enabled=config.option.debug) self.log = Producer(f"worker-{self.workerid}", enabled=config.option.debug)
self.channel = channel self.channel = channel
self.torun = TestQueue(self.channel.gateway.execmodel)
self.nextitem_index: int | None | Literal[Marker.SHUTDOWN] = None
config.pluginmanager.register(self) config.pluginmanager.register(self)
def sendevent(self, name, **kwargs): def sendevent(self, name: str, **kwargs: object) -> None:
self.log("sending", name, kwargs) self.log("sending", name, kwargs)
self.channel.send((name, kwargs)) self.channel.send((name, kwargs))
@pytest.hookimpl @pytest.hookimpl
def pytest_internalerror(self, excrepr): def pytest_internalerror(self, excrepr: object) -> None:
formatted_error = str(excrepr) formatted_error = str(excrepr)
for line in formatted_error.split("\n"): for line in formatted_error.split("\n"):
self.log("IERROR>", line) self.log("IERROR>", line)
interactor.sendevent("internal_error", formatted_error=formatted_error) interactor.sendevent("internal_error", formatted_error=formatted_error)
@pytest.hookimpl @pytest.hookimpl
def pytest_sessionstart(self, session): def pytest_sessionstart(self, session: pytest.Session) -> None:
self.session = session self.session = session
workerinfo = getinfodict() workerinfo = getinfodict()
self.sendevent("workerready", workerinfo=workerinfo) self.sendevent("workerready", workerinfo=workerinfo)
@pytest.hookimpl(hookwrapper=True) @pytest.hookimpl(hookwrapper=True)
def pytest_sessionfinish(self, exitstatus): def pytest_sessionfinish(self, exitstatus: int) -> Generator[None, object, None]:
workeroutput: dict[str, Any] = self.config.workeroutput # type: ignore[attr-defined]
# in pytest 5.0+, exitstatus is an IntEnum object # in pytest 5.0+, exitstatus is an IntEnum object
self.config.workeroutput["exitstatus"] = int(exitstatus) workeroutput["exitstatus"] = int(exitstatus)
workeroutput["shouldfail"] = self.session.shouldfail
workeroutput["shouldstop"] = self.session.shouldstop
yield yield
self.sendevent("workerfinished", workeroutput=self.config.workeroutput) self.sendevent("workerfinished", workeroutput=workeroutput)
@pytest.hookimpl @pytest.hookimpl
def pytest_collection(self, session): def pytest_collection(self) -> None:
self.sendevent("collectionstart") self.sendevent("collectionstart")
@pytest.hookimpl def handle_command(
def pytest_runtestloop(self, session): self, command: tuple[str, dict[str, Any]] | Literal[Marker.SHUTDOWN]
self.log("entering main loop") ) -> None:
torun = [] if command is Marker.SHUTDOWN:
while 1: self.torun.put(Marker.SHUTDOWN)
try: return
name, kwargs = self.channel.receive()
except EOFError: name, kwargs = command
return True
self.log("received command", name, kwargs) self.log("received command", name, kwargs)
if name == "runtests": if name == "runtests":
torun.extend(kwargs["indices"]) for i in kwargs["indices"]:
self.torun.put(i)
elif name == "runtests_all": elif name == "runtests_all":
torun.extend(range(len(session.items))) for i in range(len(self.session.items)):
self.log("items to run:", torun) self.torun.put(i)
# only run if we have an item and a next item elif name == "shutdown":
while len(torun) >= 2: self.torun.put(Marker.SHUTDOWN)
self.run_one_test(torun) elif name == "steal":
if name == "shutdown": self.steal(kwargs["indices"])
if torun:
self.run_one_test(torun) def steal(self, indices: Sequence[int]) -> None:
"""
Remove tests from the queue.
Removes either all requested tests, or none, if some of these tests
are not in the queue (for example, if they were processed already).
:param indices: indices of the tests to remove.
"""
requested_set = set(indices)
with self.torun.lock() as locked_queue:
stolen = list(item for item in locked_queue if item in requested_set)
# Stealing only if all requested tests are still pending
if len(stolen) == len(requested_set):
self.torun.replace(
item for item in locked_queue if item not in requested_set
)
else:
stolen = []
self.sendevent("unscheduled", indices=stolen)
@pytest.hookimpl
def pytest_runtestloop(self, session: pytest.Session) -> bool:
self.log("entering main loop")
self.channel.setcallback(self.handle_command, endmarker=Marker.SHUTDOWN)
self.nextitem_index = self.torun.get()
while self.nextitem_index is not Marker.SHUTDOWN:
self.run_one_test()
if session.shouldfail or session.shouldstop:
break break
return True return True
def run_one_test(self, torun): def run_one_test(self) -> None:
assert isinstance(self.nextitem_index, int)
self.item_index = self.nextitem_index
self.nextitem_index = self.torun.get()
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 Marker.SHUTDOWN:
nextitem = items[torun[0]]
else:
nextitem = None nextitem = None
else:
assert self.nextitem_index is not None
nextitem = items[self.nextitem_index]
worker_title("[pytest-xdist running] %s" % item.nodeid) worker_title("[pytest-xdist running] %s" % item.nodeid)
start = time.time() start = time.perf_counter()
self.config.hook.pytest_runtest_protocol(item=item, nextitem=nextitem) self.config.hook.pytest_runtest_protocol(item=item, nextitem=nextitem)
duration = time.time() - start duration = time.perf_counter() - start
worker_title("[pytest-xdist idle]") worker_title("[pytest-xdist idle]")
@@ -137,43 +233,52 @@ class WorkerInteractor:
"runtest_protocol_complete", item_index=self.item_index, duration=duration "runtest_protocol_complete", item_index=self.item_index, duration=duration
) )
def pytest_collection_modifyitems(self, session, config, items): def pytest_collection_modifyitems(
self,
config: pytest.Config,
items: list[pytest.Item],
) -> None:
# add the group name to nodeid as suffix if --dist=loadgroup # add the group name to nodeid as suffix if --dist=loadgroup
if config.getvalue("loadgroup"): if config.getvalue("loadgroup"):
for item in items: for item in items:
mark = item.get_closest_marker("xdist_group") gnames: set[str] = set()
if not mark: for mark in item.iter_markers("xdist_group"):
continue name = (
gname = (
mark.args[0] mark.args[0]
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) gnames.add(str(name))
if not gnames:
continue
item._nodeid = f"{item.nodeid}@{'_'.join(sorted(gnames))}"
@pytest.hookimpl @pytest.hookimpl
def pytest_collection_finish(self, session): def pytest_collection_finish(self, session: pytest.Session) -> None:
try:
topdir = str(self.config.rootpath)
except AttributeError: # pytest <= 6.1.0
topdir = str(self.config.rootdir)
self.sendevent( self.sendevent(
"collectionfinish", "collectionfinish",
topdir=topdir, topdir=str(self.config.rootpath),
ids=[item.nodeid for item in session.items], ids=[item.nodeid for item in session.items],
) )
@pytest.hookimpl @pytest.hookimpl
def pytest_runtest_logstart(self, nodeid, location): def pytest_runtest_logstart(
self,
nodeid: str,
location: tuple[str, int | None, str],
) -> None:
self.sendevent("logstart", nodeid=nodeid, location=location) self.sendevent("logstart", nodeid=nodeid, location=location)
@pytest.hookimpl @pytest.hookimpl
def pytest_runtest_logfinish(self, nodeid, location): def pytest_runtest_logfinish(
self,
nodeid: str,
location: tuple[str, int | None, str],
) -> None:
self.sendevent("logfinish", nodeid=nodeid, location=location) self.sendevent("logfinish", nodeid=nodeid, location=location)
@pytest.hookimpl @pytest.hookimpl
def pytest_runtest_logreport(self, report): def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
data = self.config.hook.pytest_report_to_serializable( data = self.config.hook.pytest_report_to_serializable(
config=self.config, report=report config=self.config, report=report
) )
@@ -184,7 +289,7 @@ class WorkerInteractor:
self.sendevent("testreport", data=data) self.sendevent("testreport", data=data)
@pytest.hookimpl @pytest.hookimpl
def pytest_collectreport(self, report): def pytest_collectreport(self, report: pytest.CollectReport) -> None:
# send only reports that have not passed to controller as optimization (#330) # send only reports that have not passed to controller as optimization (#330)
if not report.passed: if not report.passed:
data = self.config.hook.pytest_report_to_serializable( data = self.config.hook.pytest_report_to_serializable(
@@ -193,7 +298,13 @@ class WorkerInteractor:
self.sendevent("collectreport", data=data) self.sendevent("collectreport", data=data)
@pytest.hookimpl @pytest.hookimpl
def pytest_warning_recorded(self, warning_message, when, nodeid, location): def pytest_warning_recorded(
self,
warning_message: warnings.WarningMessage,
when: str,
nodeid: str,
location: tuple[str, int, str] | None,
) -> None:
self.sendevent( self.sendevent(
"warning_recorded", "warning_recorded",
warning_message_data=serialize_warning_message(warning_message), warning_message_data=serialize_warning_message(warning_message),
@@ -203,7 +314,9 @@ class WorkerInteractor:
) )
def serialize_warning_message(warning_message): def serialize_warning_message(
warning_message: warnings.WarningMessage,
) -> dict[str, Any]:
if isinstance(warning_message.message, Warning): if isinstance(warning_message.message, Warning):
message_module = type(warning_message.message).__module__ message_module = type(warning_message.message).__module__
message_class_name = type(warning_message.message).__name__ message_class_name = type(warning_message.message).__name__
@@ -211,8 +324,8 @@ def serialize_warning_message(warning_message):
# check now if we can serialize the warning arguments (#349) # check now if we can serialize the warning arguments (#349)
# if not, we will just use the exception message on the controller node # if not, we will just use the exception message on the controller node
try: try:
dumps(warning_message.message.args) execnet.dumps(warning_message.message.args)
except DumpError: except execnet.DumpError:
message_args = None message_args = None
else: else:
message_args = warning_message.message.args message_args = warning_message.message.args
@@ -237,27 +350,38 @@ def serialize_warning_message(warning_message):
"category_class_name": category_class_name, "category_class_name": category_class_name,
} }
# access private _WARNING_DETAILS because the attributes vary between Python versions # access private _WARNING_DETAILS because the attributes vary between Python versions
for attr_name in warning_message._WARNING_DETAILS: for attr_name in warning_message._WARNING_DETAILS: # type: ignore[attr-defined]
if attr_name in ("message", "category"): if attr_name in ("message", "category"):
continue continue
attr = getattr(warning_message, attr_name) attr = getattr(warning_message, attr_name)
# Check if we can serialize the warning detail, marking `None` otherwise # Check if we can serialize the warning detail, marking `None` otherwise
# Note that we need to define the attr (even as `None`) to allow deserializing # Note that we need to define the attr (even as `None`) to allow deserializing
try: try:
dumps(attr) execnet.dumps(attr)
except DumpError: except execnet.DumpError:
result[attr_name] = repr(attr) result[attr_name] = repr(attr)
else: else:
result[attr_name] = attr result[attr_name] = attr
return result return result
def getinfodict(): class WorkerInfo(TypedDict):
version: str
version_info: tuple[int, int, int, str, int]
sysplatform: str
platform: str
executable: str
cwd: str
id: str
spec: execnet.XSpec
def getinfodict() -> WorkerInfo:
import platform import platform
return dict( return dict(
version=sys.version, version=sys.version,
version_info=tuple(sys.version_info), version_info=tuple(sys.version_info), # type: ignore[typeddict-item]
sysplatform=sys.platform, sysplatform=sys.platform,
platform=platform.platform(), platform=platform.platform(),
executable=sys.executable, executable=sys.executable,
@@ -265,12 +389,7 @@ def getinfodict():
) )
def remote_initconfig(option_dict, args): def setup_config(config: pytest.Config, basetemp: str | None) -> None:
option_dict["plugins"].append("no:terminal")
return Config.fromdictargs(option_dict, args)
def setup_config(config, basetemp):
config.option.loadgroup = config.getvalue("dist") == "loadgroup" config.option.loadgroup = config.getvalue("dist") == "loadgroup"
config.option.looponfail = False config.option.looponfail = False
config.option.usepdb = False config.option.usepdb = False
@@ -282,7 +401,7 @@ def setup_config(config, basetemp):
if __name__ == "__channelexec__": if __name__ == "__channelexec__":
channel = channel # type: ignore[name-defined] # noqa: F821 channel: execnet.Channel = channel # type: ignore[name-defined] # noqa: F821, PLW0127
workerinput, args, option_dict, change_sys_path = channel.receive() # type: ignore[name-defined] workerinput, args, option_dict, change_sys_path = channel.receive() # type: ignore[name-defined]
if change_sys_path is None: if change_sys_path is None:
@@ -298,11 +417,7 @@ if __name__ == "__channelexec__":
os.environ["PYTEST_XDIST_WORKER"] = workerinput["workerid"] os.environ["PYTEST_XDIST_WORKER"] = workerinput["workerid"]
os.environ["PYTEST_XDIST_WORKER_COUNT"] = str(workerinput["workercount"]) os.environ["PYTEST_XDIST_WORKER_COUNT"] = str(workerinput["workercount"])
if hasattr(Config, "InvocationParams"):
config = _prepareconfig(args, None) config = _prepareconfig(args, None)
else:
config = remote_initconfig(option_dict, args)
config.args = args
setup_config(config, option_dict.get("basetemp")) setup_config(config, option_dict.get("basetemp"))
config._parser.prog = os.path.basename(workerinput["mainargv"][0]) config._parser.prog = os.path.basename(workerinput["mainargv"][0])

View File

@@ -1,7 +1,15 @@
from __future__ import annotations
from collections.abc import Sequence
from difflib import unified_diff from difflib import unified_diff
def report_collection_diff(from_collection, to_collection, from_id, to_id): def report_collection_diff(
from_collection: Sequence[str],
to_collection: Sequence[str],
from_id: str,
to_id: str,
) -> str | None:
"""Report the collected test difference between two nodes. """Report the collected test difference between two nodes.
:returns: detailed message describing the difference between the given :returns: detailed message describing the difference between the given
@@ -15,7 +23,8 @@ def report_collection_diff(from_collection, to_collection, from_id, to_id):
"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}\n" "{diff}\n"
"To see why this happens see Known limitations in documentation" "To see why this happens see 'Known limitations' in documentation "
"for pytest-xdist"
).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

@@ -1,5 +1,7 @@
from xdist.scheduler.each import EachScheduling # noqa from xdist.scheduler.each import EachScheduling as EachScheduling
from xdist.scheduler.load import LoadScheduling # noqa from xdist.scheduler.load import LoadScheduling as LoadScheduling
from xdist.scheduler.loadfile import LoadFileScheduling # noqa from xdist.scheduler.loadfile import LoadFileScheduling as LoadFileScheduling
from xdist.scheduler.loadscope import LoadScopeScheduling # noqa from xdist.scheduler.loadgroup import LoadGroupScheduling as LoadGroupScheduling
from xdist.scheduler.loadgroup import LoadGroupScheduling # noqa from xdist.scheduler.loadscope import LoadScopeScheduling as LoadScopeScheduling
from xdist.scheduler.protocol import Scheduling as Scheduling
from xdist.scheduler.worksteal import WorkStealingScheduling as WorkStealingScheduling

View File

@@ -1,10 +1,17 @@
from __future__ import annotations
from collections.abc import Sequence
import pytest
from xdist.remote import Producer from xdist.remote import Producer
from xdist.workermanage import parse_spec_config
from xdist.report import report_collection_diff from xdist.report import report_collection_diff
from xdist.workermanage import parse_tx_spec_config
from xdist.workermanage import WorkerController
class EachScheduling: class EachScheduling:
"""Implement scheduling of test items on all nodes """Implement scheduling of test items on all nodes.
If a node gets added after the test run is started then it is If a node gets added after the test run is started then it is
assumed to replace a node which got removed before it finished assumed to replace a node which got removed before it finished
@@ -17,13 +24,13 @@ class EachScheduling:
assigned the remaining items from the removed node. assigned the remaining items from the removed node.
""" """
def __init__(self, config, log=None): def __init__(self, config: pytest.Config, log: Producer | None = None) -> None:
self.config = config self.config = config
self.numnodes = len(parse_spec_config(config)) self.numnodes = len(parse_tx_spec_config(config))
self.node2collection = {} self.node2collection: dict[WorkerController, list[str]] = {}
self.node2pending = {} self.node2pending: dict[WorkerController, list[int]] = {}
self._started = [] self._started: list[WorkerController] = []
self._removed2pending = {} self._removed2pending: dict[WorkerController, list[int]] = {}
if log is None: if log is None:
self.log = Producer("eachsched") self.log = Producer("eachsched")
else: else:
@@ -31,12 +38,12 @@ class EachScheduling:
self.collection_is_completed = False self.collection_is_completed = False
@property @property
def nodes(self): def nodes(self) -> list[WorkerController]:
"""A list of all nodes in the scheduler.""" """A list of all nodes in the scheduler."""
return list(self.node2pending.keys()) return list(self.node2pending.keys())
@property @property
def tests_finished(self): def tests_finished(self) -> bool:
if not self.collection_is_completed: if not self.collection_is_completed:
return False return False
if self._removed2pending: if self._removed2pending:
@@ -47,8 +54,8 @@ class EachScheduling:
return True return True
@property @property
def has_pending(self): def has_pending(self) -> bool:
"""Return True if there are pending test items """Return True if there are pending test items.
This indicates that collection has finished and nodes are This indicates that collection has finished and nodes are
still processing test items, so this can be thought of as still processing test items, so this can be thought of as
@@ -59,12 +66,14 @@ class EachScheduling:
return True return True
return False return False
def add_node(self, node): def add_node(self, node: WorkerController) -> None:
assert node not in self.node2pending assert node not in self.node2pending
self.node2pending[node] = [] self.node2pending[node] = []
def add_node_collection(self, node, collection): def add_node_collection(
"""Add the collected test items from a node self, node: WorkerController, collection: Sequence[str]
) -> None:
"""Add the collected test items from a node.
Collection is complete once all nodes have submitted their Collection is complete once all nodes have submitted their
collection. In this case its pending list is set to an empty collection. In this case its pending list is set to an empty
@@ -97,29 +106,33 @@ class EachScheduling:
self.node2pending[node] = pending self.node2pending[node] = pending
break break
def mark_test_complete(self, node, item_index, duration=0): def mark_test_complete(
self, node: WorkerController, item_index: int, duration: float = 0
) -> None:
self.node2pending[node].remove(item_index) self.node2pending[node].remove(item_index)
def mark_test_pending(self, item): def mark_test_pending(self, item: str) -> None:
self.pending.insert( raise NotImplementedError()
0,
self.collection.index(item),
)
for node in self.node2pending:
self.check_schedule(node)
def remove_node(self, node): def remove_pending_tests_from_node(
self,
node: WorkerController,
indices: Sequence[int],
) -> None:
raise NotImplementedError()
def remove_node(self, node: WorkerController) -> str | None:
# KeyError if we didn't get an add_node() yet # KeyError if we didn't get an add_node() yet
pending = self.node2pending.pop(node) pending = self.node2pending.pop(node)
if not pending: if not pending:
return return None
crashitem = self.node2collection[node][pending.pop(0)] crashitem = self.node2collection[node][pending.pop(0)]
if pending: if pending:
self._removed2pending[node] = pending self._removed2pending[node] = pending
return crashitem return crashitem
def schedule(self): def schedule(self) -> None:
"""Schedule the test items on the nodes """Schedule the test items on the nodes.
If the node's pending list is empty it is a new node which If the node's pending list is empty it is a new node which
needs to run all the tests. If the pending list is already needs to run all the tests. If the pending list is already

View File

@@ -1,10 +1,14 @@
from __future__ import annotations
from collections.abc import Sequence
from itertools import cycle from itertools import cycle
from _pytest.runner import CollectReport import pytest
from xdist.remote import Producer from xdist.remote import Producer
from xdist.workermanage import parse_spec_config
from xdist.report import report_collection_diff from xdist.report import report_collection_diff
from xdist.workermanage import parse_tx_spec_config
from xdist.workermanage import WorkerController
class LoadScheduling: class LoadScheduling:
@@ -23,7 +27,7 @@ class LoadScheduling:
submit a collection. This is used to know when all nodes have submit a collection. This is used to know when all nodes have
finished collection or how large the chunks need to be created. finished collection or how large the chunks need to be created.
Attributes: Attributes::
:numnodes: The expected number of nodes taking part. The actual :numnodes: The expected number of nodes taking part. The actual
number of nodes will vary during the scheduler's lifetime as number of nodes will vary during the scheduler's lifetime as
@@ -53,25 +57,26 @@ class LoadScheduling:
:config: Config object, used for handling hooks. :config: Config object, used for handling hooks.
""" """
def __init__(self, config, log=None): def __init__(self, config: pytest.Config, log: Producer | None = None) -> None:
self.numnodes = len(parse_spec_config(config)) self.numnodes = len(parse_tx_spec_config(config))
self.node2collection = {} self.node2collection: dict[WorkerController, list[str]] = {}
self.node2pending = {} self.node2pending: dict[WorkerController, list[int]] = {}
self.pending = [] self.pending: list[int] = []
self.collection = None self.collection: list[str] | None = None
if log is None: if log is None:
self.log = Producer("loadsched") self.log = Producer("loadsched")
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) -> list[WorkerController]:
"""A list of all nodes in the scheduler.""" """A list of all nodes in the scheduler."""
return list(self.node2pending.keys()) return list(self.node2pending.keys())
@property @property
def collection_is_completed(self): def collection_is_completed(self) -> bool:
"""Boolean indication initial test collection is complete. """Boolean indication initial test collection is complete.
This is a boolean indicating all initial participating nodes This is a boolean indicating all initial participating nodes
@@ -81,7 +86,7 @@ class LoadScheduling:
return len(self.node2collection) >= self.numnodes return len(self.node2collection) >= self.numnodes
@property @property
def tests_finished(self): def tests_finished(self) -> bool:
"""Return True if all tests have been executed by the nodes.""" """Return True if all tests have been executed by the nodes."""
if not self.collection_is_completed: if not self.collection_is_completed:
return False return False
@@ -93,8 +98,8 @@ class LoadScheduling:
return True return True
@property @property
def has_pending(self): def has_pending(self) -> bool:
"""Return True if there are pending test items """Return True if there are pending test items.
This indicates that collection has finished and nodes are This indicates that collection has finished and nodes are
still processing test items, so this can be thought of as still processing test items, so this can be thought of as
@@ -107,7 +112,7 @@ class LoadScheduling:
return True return True
return False return False
def add_node(self, node): def add_node(self, node: WorkerController) -> None:
"""Add a new node to the scheduler. """Add a new node to the scheduler.
From now on the node will be allocated chunks of tests to From now on the node will be allocated chunks of tests to
@@ -119,8 +124,10 @@ class LoadScheduling:
assert node not in self.node2pending assert node not in self.node2pending
self.node2pending[node] = [] self.node2pending[node] = []
def add_node_collection(self, node, collection): def add_node_collection(
"""Add the collected test items from a node self, node: WorkerController, collection: Sequence[str]
) -> None:
"""Add the collected test items from a node.
The collection is stored in the ``.node2collection`` map. The collection is stored in the ``.node2collection`` map.
Called by the ``DSession.worker_collectionfinish`` hook. Called by the ``DSession.worker_collectionfinish`` hook.
@@ -140,8 +147,10 @@ class LoadScheduling:
return return
self.node2collection[node] = list(collection) self.node2collection[node] = list(collection)
def mark_test_complete(self, node, item_index, duration=0): def mark_test_complete(
"""Mark test item as completed by node self, node: WorkerController, item_index: int, duration: float = 0
) -> None:
"""Mark test item as completed by node.
The duration it took to execute the item is used as a hint to The duration it took to execute the item is used as a hint to
the scheduler. the scheduler.
@@ -151,7 +160,8 @@ class LoadScheduling:
self.node2pending[node].remove(item_index) self.node2pending[node].remove(item_index)
self.check_schedule(node, duration=duration) self.check_schedule(node, duration=duration)
def mark_test_pending(self, item): def mark_test_pending(self, item: str) -> None:
assert self.collection is not None
self.pending.insert( self.pending.insert(
0, 0,
self.collection.index(item), self.collection.index(item),
@@ -159,8 +169,15 @@ class LoadScheduling:
for node in self.node2pending: for node in self.node2pending:
self.check_schedule(node) self.check_schedule(node)
def check_schedule(self, node, duration=0): def remove_pending_tests_from_node(
"""Maybe schedule new items on the node self,
node: WorkerController,
indices: Sequence[int],
) -> None:
raise NotImplementedError()
def check_schedule(self, node: WorkerController, duration: float = 0) -> None:
"""Maybe schedule new items on the node.
If there are any globally pending nodes left then this will If there are any globally pending nodes left then this will
check if the given node should be given any more tests. The check if the given node should be given any more tests. The
@@ -185,14 +202,16 @@ 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()
self.log("num items waiting for node:", len(self.pending)) self.log("num items waiting for node:", len(self.pending))
def remove_node(self, node): def remove_node(self, node: WorkerController) -> str | None:
"""Remove a node from the scheduler """Remove a node from the scheduler.
This should be called either when the node crashed or at This should be called either when the node crashed or at
shutdown time. In the former case any pending items assigned shutdown time. In the former case any pending items assigned
@@ -206,17 +225,18 @@ class LoadScheduling:
""" """
pending = self.node2pending.pop(node) pending = self.node2pending.pop(node)
if not pending: if not pending:
return return None
# The node crashed, reassing pending items # The node crashed, reassing pending items
assert self.collection is not None
crashitem = self.collection[pending.pop(0)] crashitem = self.collection[pending.pop(0)]
self.pending.extend(pending) self.pending.extend(pending)
for node in self.node2pending: for node in self.node2pending:
self.check_schedule(node) self.check_schedule(node)
return crashitem return crashitem
def schedule(self): def schedule(self) -> None:
"""Initiate distribution of the test collection """Initiate distribution of the test collection.
Initiate scheduling of the items across the nodes. If this Initiate scheduling of the items across the nodes. If this
gets called again later it behaves the same as calling gets called again later it behaves the same as calling
@@ -240,11 +260,14 @@ class LoadScheduling:
return return
# Collections are identical, create the index of pending items. # Collections are identical, create the index of pending items.
self.collection = list(self.node2collection.values())[0] self.collection = next(iter(self.node2collection.values()))
self.pending[:] = range(len(self.collection)) self.pending[:] = range(len(self.collection))
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.
@@ -254,7 +277,7 @@ class LoadScheduling:
# to each node - which is suboptimal when you have less than # to each node - which is suboptimal when you have less than
# 2 * len(nodes) tests. # 2 * len(nodes) tests.
nodes = cycle(self.nodes) nodes = cycle(self.nodes)
for i in range(len(self.pending)): for _ in range(len(self.pending)):
self._send_tests(next(nodes), 1) self._send_tests(next(nodes), 1)
else: else:
# Send batches of consecutive tests. By default, pytest sorts tests # Send batches of consecutive tests. By default, pytest sorts tests
@@ -265,7 +288,8 @@ class LoadScheduling:
# how many items per node do we have about? # how many items per node do we have about?
items_per_node = len(self.collection) // len(self.node2pending) items_per_node = len(self.collection) // len(self.node2pending)
# take a fraction of tests for initial distribution # take a fraction of tests for initial distribution
node_chunksize = max(items_per_node // 4, 2) node_chunksize = min(items_per_node // 4, self.maxschedchunk)
node_chunksize = max(node_chunksize, 2)
# and initialize each node with a chunk of tests # and initialize each node with a chunk of tests
for node in self.nodes: for node in self.nodes:
self._send_tests(node, node_chunksize) self._send_tests(node, node_chunksize)
@@ -275,14 +299,14 @@ class LoadScheduling:
for node in self.nodes: for node in self.nodes:
node.shutdown() node.shutdown()
def _send_tests(self, node, num): def _send_tests(self, node: WorkerController, num: int) -> None:
tests_per_node = self.pending[:num] tests_per_node = self.pending[:num]
if tests_per_node: if tests_per_node:
del self.pending[:num] del self.pending[:num]
self.node2pending[node].extend(tests_per_node) self.node2pending[node].extend(tests_per_node)
node.send_runtest_some(tests_per_node) node.send_runtest_some(tests_per_node)
def _check_nodes_have_same_collection(self): def _check_nodes_have_same_collection(self) -> bool:
"""Return True if all nodes have collected the same items. """Return True if all nodes have collected the same items.
If collections differ, this method returns False while logging If collections differ, this method returns False while logging
@@ -300,8 +324,11 @@ class LoadScheduling:
same_collection = False same_collection = False
self.log(msg) self.log(msg)
if self.config is not None: if self.config is not None:
rep = CollectReport( rep = pytest.CollectReport(
node.gateway.id, "failed", longrepr=msg, result=[] nodeid=node.gateway.id,
outcome="failed",
longrepr=msg,
result=[],
) )
self.config.hook.pytest_collectreport(report=rep) self.config.hook.pytest_collectreport(report=rep)

View File

@@ -1,6 +1,11 @@
from .loadscope import LoadScopeScheduling from __future__ import annotations
import pytest
from xdist.remote import Producer from xdist.remote import Producer
from .loadscope import LoadScopeScheduling
class LoadFileScheduling(LoadScopeScheduling): class LoadFileScheduling(LoadScopeScheduling):
"""Implement load scheduling across nodes, but grouping test test file. """Implement load scheduling across nodes, but grouping test test file.
@@ -20,14 +25,14 @@ class LoadFileScheduling(LoadScopeScheduling):
This class behaves very much like LoadScopeScheduling, but with a file-level scope. This class behaves very much like LoadScopeScheduling, but with a file-level scope.
""" """
def __init__(self, config, log=None): def __init__(self, config: pytest.Config, log: Producer | None = None) -> None:
super().__init__(config, log) super().__init__(config, log)
if log is None: if log is None:
self.log = Producer("loadfilesched") self.log = Producer("loadfilesched")
else: else:
self.log = log.loadfilesched self.log = log.loadfilesched
def _split_scope(self, nodeid): def _split_scope(self, nodeid: str) -> str:
"""Determine the scope (grouping) of a nodeid. """Determine the scope (grouping) of a nodeid.
There are usually 3 cases for a nodeid:: There are usually 3 cases for a nodeid::
@@ -43,8 +48,11 @@ class LoadFileScheduling(LoadScopeScheduling):
This function will group tests with the scope determined by splitting This function will group tests with the scope determined by splitting
the first ``::`` from the left. That is, test will be grouped in a the first ``::`` from the left. That is, test will be grouped in a
single work unit when they reside in the same file. single work unit when they reside in the same file.
In the above example, scopes will be:: In the above example, scopes will be::
.. code-block:: text
example/loadsuite/test/test_beta.py example/loadsuite/test/test_beta.py
example/loadsuite/test/test_delta.py example/loadsuite/test/test_delta.py
example/loadsuite/epsilon/__init__.py example/loadsuite/epsilon/__init__.py

View File

@@ -1,6 +1,11 @@
from .loadscope import LoadScopeScheduling from __future__ import annotations
import pytest
from xdist.remote import Producer from xdist.remote import Producer
from .loadscope import LoadScopeScheduling
class LoadGroupScheduling(LoadScopeScheduling): class LoadGroupScheduling(LoadScopeScheduling):
"""Implement load scheduling across nodes, but grouping test by xdist_group mark. """Implement load scheduling across nodes, but grouping test by xdist_group mark.
@@ -9,14 +14,14 @@ class LoadGroupScheduling(LoadScopeScheduling):
instead of the module or class to which they belong to. instead of the module or class to which they belong to.
""" """
def __init__(self, config, log=None): def __init__(self, config: pytest.Config, log: Producer | None = None) -> None:
super().__init__(config, log) super().__init__(config, log)
if log is None: if log is None:
self.log = Producer("loadgroupsched") self.log = Producer("loadgroupsched")
else: else:
self.log = log.loadgroupsched self.log = log.loadgroupsched
def _split_scope(self, nodeid): def _split_scope(self, nodeid: str) -> str:
"""Determine the scope (grouping) of a nodeid. """Determine the scope (grouping) of a nodeid.
There are usually 3 cases for a nodeid:: There are usually 3 cases for a nodeid::

View File

@@ -1,9 +1,15 @@
from collections import OrderedDict from __future__ import annotations
from collections import OrderedDict
from collections.abc import Sequence
from typing import NoReturn
import pytest
from _pytest.runner import CollectReport
from xdist.remote 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_tx_spec_config
from xdist.workermanage import WorkerController
class LoadScopeScheduling: class LoadScopeScheduling:
@@ -21,7 +27,7 @@ class LoadScopeScheduling:
When created, ``numnodes`` defines how many nodes are expected to submit a When created, ``numnodes`` defines how many nodes are expected to submit a
collection. This is used to know when all nodes have finished collection. collection. This is used to know when all nodes have finished collection.
Attributes: Attributes::
:numnodes: The expected number of nodes taking part. The actual number of :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 nodes will vary during the scheduler's lifetime as nodes are added by
@@ -84,13 +90,13 @@ class LoadScopeScheduling:
:config: Config object, used for handling hooks. :config: Config object, used for handling hooks.
""" """
def __init__(self, config, log=None): def __init__(self, config: pytest.Config, log: Producer | None = None) -> None:
self.numnodes = len(parse_spec_config(config)) self.numnodes = len(parse_tx_spec_config(config))
self.collection = None self.collection: list[str] | None = None
self.workqueue = OrderedDict() self.workqueue: OrderedDict[str, dict[str, bool]] = OrderedDict()
self.assigned_work = OrderedDict() self.assigned_work: dict[WorkerController, dict[str, dict[str, bool]]] = {}
self.registered_collections = OrderedDict() self.registered_collections: dict[WorkerController, list[str]] = {}
if log is None: if log is None:
self.log = Producer("loadscopesched") self.log = Producer("loadscopesched")
@@ -100,12 +106,12 @@ class LoadScopeScheduling:
self.config = config self.config = config
@property @property
def nodes(self): def nodes(self) -> list[WorkerController]:
"""A list of all active nodes in the scheduler.""" """A list of all active nodes in the scheduler."""
return list(self.assigned_work.keys()) return list(self.assigned_work.keys())
@property @property
def collection_is_completed(self): def collection_is_completed(self) -> bool:
"""Boolean indication initial test collection is complete. """Boolean indication initial test collection is complete.
This is a boolean indicating all initial participating nodes have This is a boolean indicating all initial participating nodes have
@@ -115,7 +121,7 @@ class LoadScopeScheduling:
return len(self.registered_collections) >= self.numnodes return len(self.registered_collections) >= self.numnodes
@property @property
def tests_finished(self): def tests_finished(self) -> bool:
"""Return True if all tests have been executed by the nodes.""" """Return True if all tests have been executed by the nodes."""
if not self.collection_is_completed: if not self.collection_is_completed:
return False return False
@@ -130,7 +136,7 @@ class LoadScopeScheduling:
return True return True
@property @property
def has_pending(self): def has_pending(self) -> bool:
"""Return True if there are pending test items. """Return True if there are pending test items.
This indicates that collection has finished and nodes are still This indicates that collection has finished and nodes are still
@@ -146,7 +152,7 @@ class LoadScopeScheduling:
return False return False
def add_node(self, node): def add_node(self, node: WorkerController) -> None:
"""Add a new node to the scheduler. """Add a new node to the scheduler.
From now on the node will be assigned work units to be executed. From now on the node will be assigned work units to be executed.
@@ -155,9 +161,9 @@ class LoadScopeScheduling:
bootstraps a new node. bootstraps a new node.
""" """
assert node not in self.assigned_work assert node not in self.assigned_work
self.assigned_work[node] = OrderedDict() self.assigned_work[node] = {}
def remove_node(self, node): def remove_node(self, node: WorkerController) -> str | None:
"""Remove a node from the scheduler. """Remove a node from the scheduler.
This should be called either when the node crashed or at shutdown time. This should be called either when the node crashed or at shutdown time.
@@ -198,7 +204,9 @@ class LoadScopeScheduling:
return crashitem return crashitem
def add_node_collection(self, node, collection): def add_node_collection(
self, node: WorkerController, collection: Sequence[str]
) -> None:
"""Add the collected test items from a node. """Add the collected test items from a node.
The collection is stored in the ``.registered_collections`` dictionary. The collection is stored in the ``.registered_collections`` dictionary.
@@ -207,19 +215,16 @@ class LoadScopeScheduling:
- ``DSession.worker_collectionfinish``. - ``DSession.worker_collectionfinish``.
""" """
# Check that add_node() was called on the node before # Check that add_node() was called on the node before
assert node in self.assigned_work assert node in self.assigned_work
# 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(
@@ -230,7 +235,9 @@ class LoadScopeScheduling:
self.registered_collections[node] = list(collection) self.registered_collections[node] = list(collection)
def mark_test_complete(self, node, item_index, duration=0): def mark_test_complete(
self, node: WorkerController, item_index: int, duration: float = 0
) -> None:
"""Mark test item as completed by node. """Mark test item as completed by node.
Called by the hook: Called by the hook:
@@ -243,10 +250,17 @@ class LoadScopeScheduling:
self.assigned_work[node][scope][nodeid] = True self.assigned_work[node][scope][nodeid] = True
self._reschedule(node) self._reschedule(node)
def mark_test_pending(self, item): def mark_test_pending(self, item: str) -> NoReturn:
raise NotImplementedError() raise NotImplementedError()
def _assign_work_unit(self, node): def remove_pending_tests_from_node(
self,
node: WorkerController,
indices: Sequence[int],
) -> None:
raise NotImplementedError()
def _assign_work_unit(self, node: WorkerController) -> None:
"""Assign a work unit to a node.""" """Assign a work unit to a node."""
assert self.workqueue assert self.workqueue
@@ -254,7 +268,7 @@ class LoadScopeScheduling:
scope, work_unit = self.workqueue.popitem(last=False) scope, work_unit = self.workqueue.popitem(last=False)
# Keep track of the assigned work # Keep track of the assigned work
assigned_to_node = self.assigned_work.setdefault(node, default=OrderedDict()) assigned_to_node = self.assigned_work.setdefault(node, {})
assigned_to_node[scope] = work_unit assigned_to_node[scope] = work_unit
# Ask the node to execute the workload # Ask the node to execute the workload
@@ -267,7 +281,7 @@ class LoadScopeScheduling:
node.send_runtest_some(nodeids_indexes) node.send_runtest_some(nodeids_indexes)
def _split_scope(self, nodeid): def _split_scope(self, nodeid: str) -> str:
"""Determine the scope (grouping) of a nodeid. """Determine the scope (grouping) of a nodeid.
There are usually 3 cases for a nodeid:: There are usually 3 cases for a nodeid::
@@ -291,18 +305,17 @@ class LoadScopeScheduling:
""" """
return nodeid.rsplit("::", 1)[0] return nodeid.rsplit("::", 1)[0]
def _pending_of(self, workload): def _pending_of(self, workload: dict[str, dict[str, bool]]) -> int:
"""Return the number of pending tests in a workload.""" """Return the number of pending tests in a workload."""
pending = sum(list(scope.values()).count(False) for scope in workload.values()) pending = sum(list(scope.values()).count(False) for scope in workload.values())
return pending return pending
def _reschedule(self, node): def _reschedule(self, node: WorkerController) -> None:
"""Maybe schedule new items on the node. """Maybe schedule new items on the node.
If there are any globally pending work units left then this will check If there are any globally pending work units left then this will check
if the given node should be given any more tests. if the given node should be given any more tests.
""" """
# Do not add more work to a node shutting down # Do not add more work to a node shutting down
if node.shutting_down: if node.shutting_down:
return return
@@ -322,7 +335,7 @@ class LoadScopeScheduling:
# Pop one unit of work and assign it # Pop one unit of work and assign it
self._assign_work_unit(node) self._assign_work_unit(node)
def schedule(self): def schedule(self) -> None:
"""Initiate distribution of the test collection. """Initiate distribution of the test collection.
Initiate scheduling of the items across the nodes. If this gets called Initiate scheduling of the items across the nodes. If this gets called
@@ -352,21 +365,32 @@ class LoadScopeScheduling:
return return
# Determine chunks of work (scopes) # Determine chunks of work (scopes)
unsorted_workqueue: dict[str, dict[str, bool]] = {}
for nodeid in self.collection: for nodeid in self.collection:
scope = self._split_scope(nodeid) scope = self._split_scope(nodeid)
work_unit = self.workqueue.setdefault(scope, default=OrderedDict()) work_unit = unsorted_workqueue.setdefault(scope, {})
work_unit[nodeid] = False work_unit[nodeid] = False
if self.config.option.loadscopereorder:
# Insert tests scopes into work queue ordered by number of tests.
for scope, nodeids in sorted(
unsorted_workqueue.items(), key=lambda item: -len(item[1])
):
self.workqueue[scope] = nodeids
else:
for scope, nodeids in unsorted_workqueue.items():
self.workqueue[scope] = nodeids
# Avoid having more workers than work # Avoid having more workers than work
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("Shutting 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()
self.log("Shutting 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
@@ -382,7 +406,7 @@ class LoadScopeScheduling:
for node in self.nodes: for node in self.nodes:
node.shutdown() node.shutdown()
def _check_nodes_have_same_collection(self): def _check_nodes_have_same_collection(self) -> bool:
"""Return True if all nodes have collected the same items. """Return True if all nodes have collected the same items.
If collections differ, this method returns False while logging If collections differ, this method returns False while logging
@@ -403,10 +427,12 @@ class LoadScopeScheduling:
same_collection = False same_collection = False
self.log(msg) self.log(msg)
if self.config is None: rep = pytest.CollectReport(
continue nodeid=node.gateway.id,
outcome="failed",
rep = CollectReport(node.gateway.id, "failed", longrepr=msg, result=[]) longrepr=msg,
result=[],
)
self.config.hook.pytest_collectreport(report=rep) self.config.hook.pytest_collectreport(report=rep)
return same_collection return same_collection

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Protocol
from xdist.workermanage import WorkerController
class Scheduling(Protocol):
@property
def nodes(self) -> list[WorkerController]: ...
@property
def collection_is_completed(self) -> bool: ...
@property
def tests_finished(self) -> bool: ...
@property
def has_pending(self) -> bool: ...
def add_node(self, node: WorkerController) -> None: ...
def add_node_collection(
self,
node: WorkerController,
collection: Sequence[str],
) -> None: ...
def mark_test_complete(
self,
node: WorkerController,
item_index: int,
duration: float = 0,
) -> None: ...
def mark_test_pending(self, item: str) -> None: ...
def remove_pending_tests_from_node(
self,
node: WorkerController,
indices: Sequence[int],
) -> None: ...
def remove_node(self, node: WorkerController) -> str | None: ...
def schedule(self) -> None: ...

View File

@@ -0,0 +1,345 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import NamedTuple
import pytest
from xdist.remote import Producer
from xdist.report import report_collection_diff
from xdist.workermanage import parse_tx_spec_config
from xdist.workermanage import WorkerController
class NodePending(NamedTuple):
node: WorkerController
pending: list[int]
# 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: pytest.Config, log: Producer | None = None) -> None:
self.numnodes = len(parse_tx_spec_config(config))
self.node2collection: dict[WorkerController, list[str]] = {}
self.node2pending: dict[WorkerController, list[int]] = {}
self.pending: list[int] = []
self.collection: list[str] | None = None
if log is None:
self.log = Producer("workstealsched")
else:
self.log = log.workstealsched
self.config = config
self.steal_requested_from_node: WorkerController | None = None
@property
def nodes(self) -> list[WorkerController]:
"""A list of all nodes in the scheduler."""
return list(self.node2pending.keys())
@property
def collection_is_completed(self) -> bool:
"""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) -> bool:
"""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) -> bool:
"""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: WorkerController) -> None:
"""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: WorkerController, collection: Sequence[str]
) -> None:
"""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: WorkerController, item_index: int, duration: float | None = None
) -> 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: str) -> None:
assert self.collection is not None
self.pending.insert(
0,
self.collection.index(item),
)
self.check_schedule()
def remove_pending_tests_from_node(
self,
node: WorkerController,
indices: Sequence[int],
) -> None:
"""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) -> None:
"""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() -> list[WorkerController]:
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
assert steal_from is not None
steal_from.node.send_steal(steal_from.pending[-num_steal:])
self.steal_requested_from_node = steal_from.node
def remove_node(self, node: WorkerController) -> str | None:
"""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:
assert self.collection is not None
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) -> None:
"""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 = next(iter(self.node2collection.values()))
self.pending[:] = range(len(self.collection))
if not self.collection:
return
self.check_schedule()
def _send_tests(self, node: WorkerController, num: int) -> None:
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) -> bool:
"""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 = pytest.CollectReport(
nodeid=node.gateway.id,
outcome="failed",
longrepr=msg,
result=[],
)
self.config.hook.pytest_collectreport(report=rep)
return same_collection

View File

@@ -1,22 +1,33 @@
from __future__ import annotations
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
import enum
import fnmatch import fnmatch
import os import os
from pathlib import Path
import re import re
import sys import sys
from typing import Any
from typing import Callable
from typing import Literal
from typing import Union
import uuid import uuid
from pathlib import Path import warnings
from typing import List, Union, Sequence, Optional, Any, Tuple, Set
import pytest
import execnet import execnet
import pytest
from xdist.plugin import _sys_path
import xdist.remote import xdist.remote
from xdist.remote import Producer from xdist.remote import Producer
from xdist.plugin import _sys_path from xdist.remote import WorkerInfo
def parse_spec_config(config): def parse_tx_spec_config(config: pytest.Config) -> list[str]:
xspeclist = [] xspeclist = []
for xspec in config.getvalue("tx"): tx: list[str] = config.getvalue("tx")
for xspec in tx:
i = xspec.find("*") i = xspec.find("*")
try: try:
num = int(xspec[:i]) num = int(xspec[:i])
@@ -35,64 +46,101 @@ class NodeManager:
EXIT_TIMEOUT = 10 EXIT_TIMEOUT = 10
DEFAULT_IGNORES = [".*", "*.pyc", "*.pyo", "*~"] DEFAULT_IGNORES = [".*", "*.pyc", "*.pyo", "*~"]
def __init__(self, config, specs=None, defaultchdir="pyexecnetcache") -> None: def __init__(
self,
config: pytest.Config,
specs: Sequence[execnet.XSpec | str] | None = None,
defaultchdir: str = "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")
if self.testrunuid is None: if self.testrunuid is None:
self.testrunuid = uuid.uuid4().hex self.testrunuid = uuid.uuid4().hex
self.group = execnet.Group() self.group = execnet.Group(execmodel="main_thread_only")
for proxy_spec in self._getpxspecs():
# Proxy gateways do not run workers, and are meant to be passed with the `via` attribute
# to additional gateways.
# They are useful for running multiple workers on remote machines.
if getattr(proxy_spec, "id", None) is None:
raise pytest.UsageError(
f"Proxy gateway {proxy_spec} must include an id"
)
self.group.makegateway(proxy_spec)
if specs is None: if specs is None:
specs = self._getxspecs() specs = self._gettxspecs()
self.specs = [] self.specs: list[execnet.XSpec] = []
for spec in specs: for spec in specs:
if not isinstance(spec, execnet.XSpec): if not isinstance(spec, execnet.XSpec):
spec = execnet.XSpec(spec) spec = execnet.XSpec(spec)
if getattr(spec, "execmodel", None) != "main_thread_only":
spec = execnet.XSpec(f"execmodel=main_thread_only//{spec}")
if not spec.chdir and not spec.popen: if not spec.chdir and not spec.popen:
spec.chdir = defaultchdir spec.chdir = defaultchdir
self.group.allocate_id(spec) self.group.allocate_id(spec)
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[Tuple[Any, Any]] = set() self._rsynced_specs: set[tuple[Any, Any]] = set()
def rsync_roots(self, gateway): def rsync_roots(self, gateway: execnet.Gateway) -> None:
"""Rsync the set of roots to the node's gateway cwd.""" """Rsync the set of roots to the node's gateway cwd."""
if self.roots: if self.roots:
for root in self.roots: for root in self.roots:
self.rsync(gateway, root, **self.rsyncoptions) self.rsync(gateway, root, **self.rsyncoptions)
def setup_nodes(self, putevent): def setup_nodes(
self,
putevent: Callable[[tuple[str, dict[str, Any]]], None],
) -> list[WorkerController]:
self.config.hook.pytest_xdist_setupnodes(config=self.config, specs=self.specs) self.config.hook.pytest_xdist_setupnodes(config=self.config, specs=self.specs)
self.trace("setting up nodes") self.trace("setting up nodes")
return [self.setup_node(spec, putevent) for spec in self.specs] with ThreadPoolExecutor(max_workers=len(self.specs)) as executor:
futs = [
executor.submit(self.setup_node, spec, putevent, idx)
for idx, spec in enumerate(self.specs)
]
return [f.result() for f in futs]
def setup_node(self, spec, putevent): def setup_node(
self,
spec: execnet.XSpec,
putevent: Callable[[tuple[str, dict[str, Any]]], None],
idx: int | None = None,
) -> WorkerController:
if getattr(spec, "execmodel", None) != "main_thread_only":
spec = execnet.XSpec(f"execmodel=main_thread_only//{spec}")
if idx is not None:
spec = execnet.XSpec(f"{spec}//id=gw{idx}")
gw = self.group.makegateway(spec) gw = self.group.makegateway(spec)
self.config.hook.pytest_xdist_newgateway(gateway=gw) self.config.hook.pytest_xdist_newgateway(gateway=gw)
self.rsync_roots(gw) self.rsync_roots(gw)
node = WorkerController(self, gw, self.config, putevent) node = WorkerController(self, gw, self.config, putevent)
gw.node = node # keep the node alive # Keep the node alive.
gw.node = node # type: ignore[attr-defined]
node.setup() node.setup()
self.trace("started node %r" % node) self.trace("started node %r" % node)
return node return node
def teardown_nodes(self): def teardown_nodes(self) -> None:
self.group.terminate(self.EXIT_TIMEOUT) self.group.terminate(self.EXIT_TIMEOUT)
def _getxspecs(self): def _gettxspecs(self) -> list[execnet.XSpec]:
return [execnet.XSpec(x) for x in parse_spec_config(self.config)] return [execnet.XSpec(x) for x in parse_tx_spec_config(self.config)]
def _getrsyncdirs(self) -> List[Path]: def _getpxspecs(self) -> list[execnet.XSpec]:
return [execnet.XSpec(x) for x in self.config.getoption("px")]
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
else: else:
return [] return []
import pytest
import _pytest import _pytest
import pytest
def get_dir(p): def get_dir(p: str) -> str:
"""Return the directory path if p is a package or the path to the .py file otherwise.""" """Return the directory path if p is a package or the path to the .py file otherwise."""
stripped = p.rstrip("co") stripped = p.rstrip("co")
if os.path.basename(stripped) == "__init__.py": if os.path.basename(stripped) == "__init__.py":
@@ -110,14 +158,14 @@ class NodeManager:
candidates.extend(rsyncroots) candidates.extend(rsyncroots)
roots = [] roots = []
for root in candidates: for root in candidates:
root = Path(root).resolve() root_path = Path(root).resolve()
if not root.exists(): if not root_path.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_path not in roots:
roots.append(root) roots.append(root_path)
return roots return roots
def _getrsyncoptions(self): def _getrsyncoptions(self) -> dict[str, Any]:
"""Get options to be passed for rsync.""" """Get options to be passed for rsync."""
ignores = list(self.DEFAULT_IGNORES) ignores = list(self.DEFAULT_IGNORES)
ignores += [str(path) for path in self.config.option.rsyncignore] ignores += [str(path) for path in self.config.option.rsyncignore]
@@ -128,12 +176,21 @@ class NodeManager:
"verbose": getattr(self.config.option, "verbose", 0), "verbose": getattr(self.config.option, "verbose", 0),
} }
def rsync(self, gateway, source, notify=None, verbose=False, ignores=None): def rsync(
self,
gateway: execnet.Gateway,
source: str | os.PathLike[str],
notify: (
Callable[[str, execnet.XSpec, str | os.PathLike[str]], Any] | None
) = None,
verbose: int = False,
ignores: Sequence[str] | None = None,
) -> None:
"""Perform rsync to remote hosts for node.""" """Perform rsync to remote hosts for node."""
# XXX This changes the calling behaviour of # XXX This changes the calling behaviour of
# pytest_xdist_rsyncstart and pytest_xdist_rsyncfinish to # pytest_xdist_rsyncstart and pytest_xdist_rsyncfinish to
# be called once per rsync target. # be called once per rsync target.
rsync = HostRSync(source, verbose=verbose, ignores=ignores) rsync = HostRSync(source, verbose=verbose > 0, ignores=ignores)
spec = gateway.spec spec = gateway.spec
if spec.popen and not spec.chdir: if spec.popen and not spec.chdir:
# XXX This assumes that sources are python-packages # XXX This assumes that sources are python-packages
@@ -148,7 +205,7 @@ class NodeManager:
if (spec, source) in self._rsynced_specs: if (spec, source) in self._rsynced_specs:
return return
def finished(): def finished() -> None:
if notify: if notify:
notify("rsyncrootready", spec, source) notify("rsyncrootready", spec, source)
@@ -160,7 +217,7 @@ class NodeManager:
class HostRSync(execnet.RSync): class HostRSync(execnet.RSync):
"""RSyncer that filters out common files""" """RSyncer that filters out common files."""
PathLike = Union[str, "os.PathLike[str]"] PathLike = Union[str, "os.PathLike[str]"]
@@ -168,13 +225,13 @@ class HostRSync(execnet.RSync):
self, self,
sourcedir: PathLike, sourcedir: PathLike,
*, *,
ignores: Optional[Sequence[PathLike]] = None, ignores: Sequence[PathLike] | None = None,
**kwargs: object verbose: bool = True,
) -> None: ) -> None:
if ignores is None: if ignores is None:
ignores = [] ignores = []
self._ignores = [re.compile(fnmatch.translate(os.fspath(x))) for x in ignores] self._ignores = [re.compile(fnmatch.translate(os.fspath(x))) for x in ignores]
super().__init__(sourcedir=Path(sourcedir), **kwargs) super().__init__(sourcedir=Path(sourcedir), verbose=verbose)
def filter(self, path: PathLike) -> bool: def filter(self, path: PathLike) -> bool:
path = Path(path) path = Path(path)
@@ -184,18 +241,26 @@ class HostRSync(execnet.RSync):
else: else:
return True return True
def add_target_host(self, gateway, finished=None): def add_target_host(
self,
gateway: execnet.Gateway,
finished: Callable[[], None] | None = None,
) -> None:
remotepath = os.path.basename(self._sourcedir) remotepath = os.path.basename(self._sourcedir)
super().add_target(gateway, remotepath, finishedcallback=finished, delete=True) super().add_target(gateway, remotepath, finishedcallback=finished, delete=True)
def _report_send_file(self, gateway, modified_rel_path): def _report_send_file(
self,
gateway: execnet.Gateway, # type: ignore[override]
modified_rel_path: str,
) -> None:
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: Sequence[Path], args: List[str]) -> List[str]: 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 = []
@@ -210,7 +275,7 @@ def make_reltoroot(roots: Sequence[Path], args: List[str]) -> List[str]:
result.append(arg) result.append(arg)
continue continue
for root in roots: for root in roots:
x: Optional[Path] x: Path | None
try: try:
x = fspath.relative_to(root) x = fspath.relative_to(root)
except ValueError: except ValueError:
@@ -219,20 +284,31 @@ def make_reltoroot(roots: Sequence[Path], args: List[str]) -> List[str]:
parts[0] = root.name + "/" + str(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
class Marker(enum.Enum):
END = -1
class WorkerController: class WorkerController:
ENDMARK = -1 # Set when the worker is ready.
workerinfo: WorkerInfo
class RemoteHook: class RemoteHook:
@pytest.hookimpl(trylast=True) @pytest.hookimpl(trylast=True)
def pytest_xdist_getremotemodule(self): def pytest_xdist_getremotemodule(self) -> Any:
return xdist.remote return xdist.remote
def __init__(self, nodemanager, gateway, config, putevent): def __init__(
self,
nodemanager: NodeManager,
gateway: execnet.Gateway,
config: pytest.Config,
putevent: Callable[[tuple[str, dict[str, Any]]], None],
) -> None:
config.pluginmanager.register(self.RemoteHook()) config.pluginmanager.register(self.RemoteHook())
self.nodemanager = nodemanager self.nodemanager = nodemanager
self.putevent = putevent self.putevent = putevent
@@ -248,22 +324,23 @@ class WorkerController:
self._shutdown_sent = False self._shutdown_sent = False
self.log = Producer(f"workerctl-{gateway.id}", enabled=config.option.debug) self.log = Producer(f"workerctl-{gateway.id}", enabled=config.option.debug)
def __repr__(self): def __repr__(self) -> str:
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) -> bool:
return self._down or self._shutdown_sent return self._down or self._shutdown_sent
def setup(self): def setup(self) -> None:
self.log("setting up worker session") self.log("setting up worker session")
# Cache rinfo for backward compatibility, since pytest-cov
# accesses rinfo while the main thread is busy executing our
# remote_exec call, which triggers a deadlock error for the
# main_thread_only execmodel if the rinfo has not been cached.
self.gateway._rinfo()
spec = self.gateway.spec spec = self.gateway.spec
if hasattr(self.config, "invocation_params"):
args = [str(x) for x in self.config.invocation_params.args or ()] args = [str(x) for x in self.config.invocation_params.args or ()]
option_dict = {} option_dict = {}
else:
args = self.config.args
option_dict = vars(self.config.option)
if not spec.popen or spec.chdir: if not spec.popen or spec.chdir:
args = make_reltoroot(self.nodemanager.roots, args) args = make_reltoroot(self.nodemanager.roots, args)
if spec.popen: if spec.popen:
@@ -280,10 +357,11 @@ class WorkerController:
change_sys_path = _sys_path if self.gateway.spec.popen else None change_sys_path = _sys_path if self.gateway.spec.popen else None
self.channel.send((self.workerinput, args, option_dict, change_sys_path)) self.channel.send((self.workerinput, args, option_dict, change_sys_path))
if self.putevent: # putevent is only None in a test.
self.channel.setcallback(self.process_from_remote, endmarker=self.ENDMARK) if self.putevent: # type: ignore[truthy-function]
self.channel.setcallback(self.process_from_remote, endmarker=Marker.END)
def ensure_teardown(self): def ensure_teardown(self) -> None:
if hasattr(self, "channel"): if hasattr(self, "channel"):
if not self.channel.isclosed(): if not self.channel.isclosed():
self.log("closing", self.channel) self.log("closing", self.channel)
@@ -294,13 +372,16 @@ class WorkerController:
self.gateway.exit() self.gateway.exit()
# del self.gateway # del self.gateway
def send_runtest_some(self, indices): def send_runtest_some(self, indices: Sequence[int]) -> None:
self.sendcommand("runtests", indices=indices) self.sendcommand("runtests", indices=indices)
def send_runtest_all(self): def send_runtest_all(self) -> None:
self.sendcommand("runtests_all") self.sendcommand("runtests_all")
def shutdown(self): def send_steal(self, indices: Sequence[int]) -> None:
self.sendcommand("steal", indices=indices)
def shutdown(self) -> None:
if not self._down: if not self._down:
try: try:
self.sendcommand("shutdown") self.sendcommand("shutdown")
@@ -308,17 +389,19 @@ class WorkerController:
pass pass
self._shutdown_sent = True self._shutdown_sent = True
def sendcommand(self, name, **kwargs): def sendcommand(self, name: str, **kwargs: object) -> None:
"""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: str, **kwargs: object) -> None:
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(
"""this gets called for each object we receive from self, eventcall: tuple[str, dict[str, Any]] | Literal[Marker.END]
) -> None:
"""This gets called for each object we receive from
the other side and if the channel closes. the other side and if the channel closes.
Note that channel callbacks run in the receiver Note that channel callbacks run in the receiver
@@ -326,8 +409,8 @@ class WorkerController:
avoid raising exceptions or doing heavy work. avoid raising exceptions or doing heavy work.
""" """
try: try:
if eventcall == self.ENDMARK: if eventcall is Marker.END:
err = self.channel._getremoteerror() err: object | None = self.channel._getremoteerror() # type: ignore[no-untyped-call]
if not self._down: if not self._down:
if not err or isinstance(err, EOFError): if not err or isinstance(err, EOFError):
err = "Not properly terminated" # lost connection? err = "Not properly terminated" # lost connection?
@@ -336,7 +419,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":
@@ -359,6 +442,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,
@@ -367,16 +452,6 @@ class WorkerController:
nodeid=kwargs["nodeid"], nodeid=kwargs["nodeid"],
fslocation=kwargs["nodeid"], fslocation=kwargs["nodeid"],
) )
elif eventname == "warning_captured":
warning_message = unserialize_warning_message(
kwargs["warning_message_data"]
)
self.notify_inproc(
eventname,
warning_message=warning_message,
when=kwargs["when"],
item=kwargs["item"],
)
elif eventname == "warning_recorded": elif eventname == "warning_recorded":
warning_message = unserialize_warning_message( warning_message = unserialize_warning_message(
kwargs["warning_message_data"] kwargs["warning_message_data"]
@@ -389,22 +464,19 @@ 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
except: # noqa except BaseException:
from _pytest._code import ExceptionInfo excinfo = pytest.ExceptionInfo.from_current()
excinfo = ExceptionInfo.from_current()
print("!" * 20, excinfo) print("!" * 20, excinfo)
self.config.notify_exception(excinfo) self.config.notify_exception(excinfo)
self.shutdown() self.shutdown()
self.notify_inproc("errordown", node=self, error=excinfo) self.notify_inproc("errordown", node=self, error=excinfo)
def unserialize_warning_message(data): def unserialize_warning_message(data: dict[str, Any]) -> warnings.WarningMessage:
import warnings
import importlib import importlib
if data["message_module"]: if data["message_module"]:
@@ -442,4 +514,4 @@ def unserialize_warning_message(data):
continue continue
kwargs[attr_name] = data[attr_name] kwargs[attr_name] = data[attr_name]
return warnings.WarningMessage(**kwargs) # type: ignore[arg-type] return warnings.WarningMessage(**kwargs)

View File

@@ -1,11 +1,12 @@
from __future__ import annotations
import os import os
import re import re
import shutil import shutil
from typing import Dict from typing import cast
from typing import List
from typing import Tuple
import pytest import pytest
import xdist import xdist
@@ -101,21 +102,51 @@ 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_exitfirst_waits_for_workers_to_finish(
self, pytester: pytest.Pytester
) -> None:
"""The DSession waits for workers before exiting early on failure.
When -x/--exitfirst is set, the DSession wait for all workers to finish
before raising an Interrupt exception. This prevents reports from the
faiing test and other tests from being discarded.
"""
p1 = pytester.makepyfile( p1 = pytester.makepyfile(
""" """
import time
def test_fail1(): def test_fail1():
time.sleep(0.1)
assert 0 assert 0
def test_fail2(): def test_fail2():
time.sleep(0.2)
def test_fail3():
time.sleep(0.3)
assert 0 assert 0
def test_fail4():
time.sleep(0.3)
def test_fail5():
time.sleep(0.3)
def test_fail6():
time.sleep(0.3)
""" """
) )
result = pytester.runpytest(p1, "-x", "-v", "-n1") # Two workers are used
result = pytester.runpytest(p1, "-x", "-rA", "-v", "-n2")
assert result.ret == 2 assert result.ret == 2
result.stdout.fnmatch_lines(["*Interrupted: stopping*1*", "*1 failed*"]) # DSession should stop when the first failure is reached. Two failures
# may actually occur, due to timing.
outcomes = result.parseoutcomes()
assert "failed" in outcomes, "Expected at least one failure"
assert 1 <= outcomes["failed"] <= 2, "Expected no more than 2 failures"
def test_basetemp_in_subprocesses(self, pytester: pytest.Pytester) -> None: def test_basetemp_in_subprocesses(self, pytester: pytest.Pytester) -> None:
p1 = pytester.makepyfile( p1 = pytester.makepyfile(
@@ -151,7 +182,12 @@ 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
def test_dist_tests_with_crash(self, pytester: pytest.Pytester) -> None: def test_dist_tests_with_crash(self, pytester: pytest.Pytester) -> None:
@@ -188,7 +224,7 @@ class TestDistribution:
assert result.ret == 1 assert result.ret == 1
def test_distribution_rsyncdirs_example( def test_distribution_rsyncdirs_example(
self, pytester: pytest.Pytester, monkeypatch self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
# use a custom plugin that has a custom command-line option to ensure # use a custom plugin that has a custom command-line option to ensure
# this is propagated to workers (see #491) # this is propagated to workers (see #491)
@@ -230,16 +266,13 @@ class TestDistribution:
"-pfoobarplugin", "-pfoobarplugin",
"--foobar=123", "--foobar=123",
"--dist=load", "--dist=load",
"--rsyncdir=%(subdir)s" % locals(), f"--rsyncdir={subdir}",
"--tx=popen//chdir=%(dest)s" % locals(), f"--tx=popen//chdir={dest}",
p, p,
) )
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*",
] ]
) )
@@ -276,7 +309,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
@@ -379,7 +416,7 @@ class TestDistEach:
class TestTerminalReporting: class TestTerminalReporting:
@pytest.mark.parametrize("verbosity", ["", "-q", "-v"]) @pytest.mark.parametrize("verbosity", ["", "-q", "-v"])
def test_output_verbosity(self, pytester, verbosity: str) -> None: def test_output_verbosity(self, pytester: pytest.Pytester, verbosity: str) -> None:
pytester.makepyfile( pytester.makepyfile(
""" """
def test_ok(): def test_ok():
@@ -393,14 +430,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(
@@ -445,7 +482,7 @@ class TestTerminalReporting:
) )
def test_logfinish_hook(self, pytester: pytest.Pytester) -> None: def test_logfinish_hook(self, pytester: pytest.Pytester) -> None:
"""Ensure the pytest_runtest_logfinish hook is being properly handled""" """Ensure the pytest_runtest_logfinish hook is being properly handled."""
pytester.makeconftest( pytester.makeconftest(
""" """
def pytest_runtest_logfinish(): def pytest_runtest_logfinish():
@@ -574,9 +611,9 @@ def test_fixture_teardown_failure(pytester: pytest.Pytester) -> None:
def test_config_initialization( def test_config_initialization(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, pytestconfig pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
"""Ensure workers and controller are initialized consistently. Integration test for #445""" """Ensure workers and controller are initialized consistently. Integration test for #445."""
pytester.makepyfile( pytester.makepyfile(
**{ **{
"dir_a/test_foo.py": """ "dir_a/test_foo.py": """
@@ -599,8 +636,8 @@ def test_config_initialization(
@pytest.mark.parametrize("when", ["setup", "call", "teardown"]) @pytest.mark.parametrize("when", ["setup", "call", "teardown"])
def test_crashing_item(pytester, when) -> None: def test_crashing_item(pytester: pytest.Pytester, when: str) -> 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] = "os._exit(1)" code[when] = "os._exit(1)"
p = pytester.makepyfile( p = pytester.makepyfile(
@@ -620,9 +657,7 @@ def test_crashing_item(pytester, when) -> None:
def test_ok(): def test_ok():
pass pass
""".format( """.format(**code)
**code
)
) )
passes = 2 if when == "teardown" else 1 passes = 2 if when == "teardown" else 1
result = pytester.runpytest("-n2", p) result = pytester.runpytest("-n2", p)
@@ -732,8 +767,8 @@ def test_tmpdir_disabled(pytester: pytest.Pytester) -> None:
@pytest.mark.parametrize("plugin", ["xdist.looponfail"]) @pytest.mark.parametrize("plugin", ["xdist.looponfail"])
def test_sub_plugins_disabled(pytester, plugin) -> None: def test_sub_plugins_disabled(pytester: pytest.Pytester, plugin: str) -> None:
"""Test that xdist doesn't break if we disable any of its sub-plugins. (#32)""" """Test that xdist doesn't break if we disable any of its sub-plugins (#32)."""
p1 = pytester.makepyfile( p1 = pytester.makepyfile(
""" """
def test_ok(): def test_ok():
@@ -747,7 +782,7 @@ def test_sub_plugins_disabled(pytester, plugin) -> None:
class TestWarnings: class TestWarnings:
@pytest.mark.parametrize("n", ["-n0", "-n1"]) @pytest.mark.parametrize("n", ["-n0", "-n1"])
def test_warnings(self, pytester, n) -> None: def test_warnings(self, pytester: pytest.Pytester, n: str) -> None:
pytester.makepyfile( pytester.makepyfile(
""" """
import warnings, py, pytest import warnings, py, pytest
@@ -763,9 +798,7 @@ class TestWarnings:
def test_warning_captured_deprecated_in_pytest_6( def test_warning_captured_deprecated_in_pytest_6(
self, pytester: pytest.Pytester self, pytester: pytest.Pytester
) -> None: ) -> None:
""" """Do not trigger the deprecated pytest_warning_captured hook in pytest 6+ (#562)."""
Do not trigger the deprecated pytest_warning_captured hook in pytest 6+ (#562)
"""
from _pytest import hookspec from _pytest import hookspec
if not hasattr(hookspec, "pytest_warning_captured"): if not hasattr(hookspec, "pytest_warning_captured"):
@@ -795,9 +828,9 @@ class TestWarnings:
result.stdout.no_fnmatch_line("*this hook should not be called in this version") result.stdout.no_fnmatch_line("*this hook should not be called in this version")
@pytest.mark.parametrize("n", ["-n0", "-n1"]) @pytest.mark.parametrize("n", ["-n0", "-n1"])
def test_custom_subclass(self, pytester, n) -> None: def test_custom_subclass(self, pytester: pytest.Pytester, n: str) -> None:
"""Check that warning subclasses that don't honor the args attribute don't break """Check that warning subclasses that don't honor the args attribute don't break
pytest-xdist (#344) pytest-xdist (#344).
""" """
pytester.makepyfile( pytester.makepyfile(
""" """
@@ -819,7 +852,7 @@ class TestWarnings:
result.stdout.fnmatch_lines(["*MyWarning*", "*1 passed, 1 warning*"]) result.stdout.fnmatch_lines(["*MyWarning*", "*1 passed, 1 warning*"])
@pytest.mark.parametrize("n", ["-n0", "-n1"]) @pytest.mark.parametrize("n", ["-n0", "-n1"])
def test_unserializable_arguments(self, pytester, n) -> None: def test_unserializable_arguments(self, pytester: pytest.Pytester, n: str) -> None:
"""Check that warnings with unserializable arguments are handled correctly (#349).""" """Check that warnings with unserializable arguments are handled correctly (#349)."""
pytester.makepyfile( pytester.makepyfile(
""" """
@@ -837,7 +870,9 @@ class TestWarnings:
result.stdout.fnmatch_lines(["*UserWarning*foo.txt*", "*1 passed, 1 warning*"]) result.stdout.fnmatch_lines(["*UserWarning*foo.txt*", "*1 passed, 1 warning*"])
@pytest.mark.parametrize("n", ["-n0", "-n1"]) @pytest.mark.parametrize("n", ["-n0", "-n1"])
def test_unserializable_warning_details(self, pytester, n) -> None: def test_unserializable_warning_details(
self, pytester: pytest.Pytester, n: str
) -> None:
"""Check that warnings with unserializable _WARNING_DETAILS are """Check that warnings with unserializable _WARNING_DETAILS are
handled correctly (#379). handled correctly (#379).
""" """
@@ -1017,7 +1052,7 @@ class TestNodeFailure:
@pytest.mark.parametrize("n", [0, 2]) @pytest.mark.parametrize("n", [0, 2])
def test_worker_id_fixture(pytester, n) -> None: def test_worker_id_fixture(pytester: pytest.Pytester, n: int) -> None:
import glob import glob
f = pytester.makepyfile( f = pytester.makepyfile(
@@ -1033,8 +1068,8 @@ def test_worker_id_fixture(pytester, n) -> None:
result.stdout.fnmatch_lines("* 2 passed in *") result.stdout.fnmatch_lines("* 2 passed in *")
worker_ids = set() worker_ids = set()
for fname in glob.glob(str(pytester.path / "*.txt")): for fname in glob.glob(str(pytester.path / "*.txt")):
with open(fname) as f: with open(fname) as fp:
worker_ids.add(f.read().strip()) worker_ids.add(fp.read().strip())
if n == 0: if n == 0:
assert worker_ids == {"master"} assert worker_ids == {"master"}
else: else:
@@ -1042,7 +1077,7 @@ def test_worker_id_fixture(pytester, n) -> None:
@pytest.mark.parametrize("n", [0, 2]) @pytest.mark.parametrize("n", [0, 2])
def test_testrun_uid_fixture(pytester, n) -> None: def test_testrun_uid_fixture(pytester: pytest.Pytester, n: int) -> None:
import glob import glob
f = pytester.makepyfile( f = pytester.makepyfile(
@@ -1058,14 +1093,14 @@ def test_testrun_uid_fixture(pytester, n) -> None:
result.stdout.fnmatch_lines("* 2 passed in *") result.stdout.fnmatch_lines("* 2 passed in *")
testrun_uids = set() testrun_uids = set()
for fname in glob.glob(str(pytester.path / "*.txt")): for fname in glob.glob(str(pytester.path / "*.txt")):
with open(fname) as f: with open(fname) as fp:
testrun_uids.add(f.read().strip()) testrun_uids.add(fp.read().strip())
assert len(testrun_uids) == 1 assert len(testrun_uids) == 1
assert len(testrun_uids.pop()) == 32 assert len(testrun_uids.pop()) == 32
@pytest.mark.parametrize("tb", ["auto", "long", "short", "no", "line", "native"]) @pytest.mark.parametrize("tb", ["auto", "long", "short", "no", "line", "native"])
def test_error_report_styles(pytester, tb) -> None: def test_error_report_styles(pytester: pytest.Pytester, tb: str) -> None:
pytester.makepyfile( pytester.makepyfile(
""" """
import pytest import pytest
@@ -1079,14 +1114,11 @@ def test_error_report_styles(pytester, tb) -> None:
result.assert_outcomes(failed=1) result.assert_outcomes(failed=1)
def test_color_yes_collection_on_non_atty(pytester, request) -> None: def test_color_yes_collection_on_non_atty(pytester: pytest.Pytester) -> None:
"""skip collect progress report when working on non-terminals. """Skip collect progress report when working on non-terminals.
Similar to pytest-dev/pytest#1397 Similar to pytest-dev/pytest#1397
""" """
tr = request.config.pluginmanager.getplugin("terminalreporter")
if not hasattr(tr, "isatty"):
pytest.skip("only valid for newer pytest versions")
pytester.makepyfile( pytester.makepyfile(
""" """
import pytest import pytest
@@ -1099,14 +1131,13 @@ 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: pytest.Pytester) -> None:
""" """No output when terminal plugin is disabled."""
No output when terminal plugin is disabled
"""
pytester.makepyfile( pytester.makepyfile(
""" """
def test_1(): def test_1():
@@ -1120,9 +1151,7 @@ def test_without_terminal_plugin(pytester, request) -> None:
def test_internal_error_with_maxfail(pytester: pytest.Pytester) -> None: def test_internal_error_with_maxfail(pytester: pytest.Pytester) -> None:
""" """Internal error when using --maxfail option (#62, #65)."""
Internal error when using --maxfail option (#62, #65).
"""
pytester.makepyfile( pytester.makepyfile(
""" """
import pytest import pytest
@@ -1138,10 +1167,25 @@ def test_internal_error_with_maxfail(pytester: pytest.Pytester) -> None:
""" """
) )
result = pytester.runpytest_subprocess("--maxfail=1", "-n1") result = pytester.runpytest_subprocess("--maxfail=1", "-n1")
result.stdout.fnmatch_lines(["* 1 error in *"]) result.stdout.re_match_lines([".* [12] errors? in .*"])
assert "INTERNALERROR" not in result.stderr.str() assert "INTERNALERROR" not in result.stderr.str()
def test_maxfail_causes_early_termination(pytester: pytest.Pytester) -> None:
"""Ensure subsequent tests on a worker aren't run when using --maxfail (#1024)."""
pytester.makepyfile(
"""
def test1():
assert False
def test2():
pass
"""
)
result = pytester.runpytest_subprocess("--maxfail=1", "-n 1")
result.assert_outcomes(failed=1)
def test_internal_errors_propagate_to_controller(pytester: pytest.Pytester) -> None: def test_internal_errors_propagate_to_controller(pytester: pytest.Pytester) -> None:
pytester.makeconftest( pytester.makeconftest(
""" """
@@ -1194,6 +1238,40 @@ class TestLoadScope:
"test_a.py::TestB", result.outlines "test_a.py::TestB", result.outlines
) in ({"gw0": 10}, {"gw1": 10}) ) in ({"gw0": 10}, {"gw1": 10})
def test_workqueue_ordered_by_size(self, pytester: pytest.Pytester) -> None:
test_file = """
import pytest
@pytest.mark.parametrize('i', range({}))
def test(i):
pass
"""
pytester.makepyfile(test_a=test_file.format(10), test_b=test_file.format(20))
result = pytester.runpytest("-n2", "--dist=loadscope", "-v")
assert get_workers_and_test_count_by_prefix(
"test_a.py::test", result.outlines
) == {"gw1": 10}
assert get_workers_and_test_count_by_prefix(
"test_b.py::test", result.outlines
) == {"gw0": 20}
def test_workqueue_ordered_by_input(self, pytester: pytest.Pytester) -> None:
test_file = """
import pytest
@pytest.mark.parametrize('i', range({}))
def test(i):
pass
"""
pytester.makepyfile(test_a=test_file.format(10), test_b=test_file.format(20))
result = pytester.runpytest(
"-n2", "--dist=loadscope", "--no-loadscope-reorder", "-v"
)
assert get_workers_and_test_count_by_prefix(
"test_a.py::test", result.outlines
) == {"gw0": 10}
assert get_workers_and_test_count_by_prefix(
"test_b.py::test", result.outlines
) == {"gw1": 20}
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)."""
test_file1 = """ test_file1 = """
@@ -1311,7 +1389,7 @@ class TestFileScope:
class TestGroupScope: class TestGroupScope:
def test_by_module(self, testdir): def test_by_module(self, pytester: pytest.Pytester) -> None:
test_file = """ test_file = """
import pytest import pytest
class TestA: class TestA:
@@ -1320,8 +1398,8 @@ class TestGroupScope:
def test(self, i): def test(self, i):
pass pass
""" """
testdir.makepyfile(test_a=test_file, test_b=test_file) pytester.makepyfile(test_a=test_file, test_b=test_file)
result = testdir.runpytest("-n2", "--dist=loadgroup", "-v") result = pytester.runpytest("-n2", "--dist=loadgroup", "-v")
test_a_workers_and_test_count = get_workers_and_test_count_by_prefix( test_a_workers_and_test_count = get_workers_and_test_count_by_prefix(
"test_a.py::TestA", result.outlines "test_a.py::TestA", result.outlines
) )
@@ -1342,8 +1420,8 @@ class TestGroupScope:
== test_b_workers_and_test_count.items() == test_b_workers_and_test_count.items()
) )
def test_by_class(self, testdir): def test_by_class(self, pytester: pytest.Pytester) -> None:
testdir.makepyfile( pytester.makepyfile(
test_a=""" test_a="""
import pytest import pytest
class TestA: class TestA:
@@ -1358,7 +1436,7 @@ class TestGroupScope:
pass pass
""" """
) )
result = testdir.runpytest("-n2", "--dist=loadgroup", "-v") result = pytester.runpytest("-n2", "--dist=loadgroup", "-v")
test_a_workers_and_test_count = get_workers_and_test_count_by_prefix( test_a_workers_and_test_count = get_workers_and_test_count_by_prefix(
"test_a.py::TestA", result.outlines "test_a.py::TestA", result.outlines
) )
@@ -1379,7 +1457,7 @@ class TestGroupScope:
== test_b_workers_and_test_count.items() == test_b_workers_and_test_count.items()
) )
def test_module_single_start(self, testdir): def test_module_single_start(self, pytester: pytest.Pytester) -> None:
test_file1 = """ test_file1 = """
import pytest import pytest
@pytest.mark.xdist_group(name="xdist_group") @pytest.mark.xdist_group(name="xdist_group")
@@ -1394,15 +1472,15 @@ class TestGroupScope:
def test_2(): def test_2():
pass pass
""" """
testdir.makepyfile(test_a=test_file1, test_b=test_file1, test_c=test_file2) pytester.makepyfile(test_a=test_file1, test_b=test_file1, test_c=test_file2)
result = testdir.runpytest("-n2", "--dist=loadgroup", "-v") result = pytester.runpytest("-n2", "--dist=loadgroup", "-v")
a = get_workers_and_test_count_by_prefix("test_a.py::test", result.outlines) a = get_workers_and_test_count_by_prefix("test_a.py::test", result.outlines)
b = get_workers_and_test_count_by_prefix("test_b.py::test", result.outlines) b = get_workers_and_test_count_by_prefix("test_b.py::test", result.outlines)
c = get_workers_and_test_count_by_prefix("test_c.py::test_2", result.outlines) c = get_workers_and_test_count_by_prefix("test_c.py::test_2", result.outlines)
assert a.keys() == b.keys() and b.keys() == c.keys() assert a.keys() == b.keys() and b.keys() == c.keys()
def test_with_two_group_names(self, testdir): def test_with_two_group_names(self, pytester: pytest.Pytester) -> None:
test_file = """ test_file = """
import pytest import pytest
@pytest.mark.xdist_group(name="group1") @pytest.mark.xdist_group(name="group1")
@@ -1412,8 +1490,8 @@ class TestGroupScope:
def test_2(): def test_2():
pass pass
""" """
testdir.makepyfile(test_a=test_file, test_b=test_file) pytester.makepyfile(test_a=test_file, test_b=test_file)
result = testdir.runpytest("-n2", "--dist=loadgroup", "-v") result = pytester.runpytest("-n2", "--dist=loadgroup", "-v")
a_1 = get_workers_and_test_count_by_prefix("test_a.py::test_1", result.outlines) a_1 = get_workers_and_test_count_by_prefix("test_a.py::test_1", result.outlines)
a_2 = get_workers_and_test_count_by_prefix("test_a.py::test_2", result.outlines) a_2 = get_workers_and_test_count_by_prefix("test_a.py::test_2", result.outlines)
b_1 = get_workers_and_test_count_by_prefix("test_b.py::test_1", result.outlines) b_1 = get_workers_and_test_count_by_prefix("test_b.py::test_1", result.outlines)
@@ -1421,6 +1499,46 @@ class TestGroupScope:
assert a_1.keys() == b_1.keys() and a_2.keys() == b_2.keys() assert a_1.keys() == b_1.keys() and a_2.keys() == b_2.keys()
def test_multiple_group_marks(self, pytester: pytest.Pytester) -> None:
test_file = """
import pytest
@pytest.mark.xdist_group(name="group1")
@pytest.mark.xdist_group(name="group2")
def test_1():
pass
"""
pytester.makepyfile(test_a=test_file, test_b=test_file)
result = pytester.runpytest("-n2", "--dist=loadgroup", "-v")
res = parse_tests_and_workers_from_output(result.outlines)
assert len(res) == 2
# get test names
a_1 = next(t[2] for t in res if "test_a.py::test_1" in t[2])
b_1 = next(t[2] for t in res if "test_b.py::test_1" in t[2])
# check groups
assert a_1.split("@")[1] == b_1.split("@")[1] == "group1_group2"
def test_multiple_group_order(self, pytester: pytest.Pytester) -> None:
test_file = """
import pytest
@pytest.mark.xdist_group(name="b")
@pytest.mark.xdist_group(name="d")
@pytest.mark.xdist_group(name="c")
@pytest.mark.xdist_group(name="c2")
@pytest.mark.xdist_group(name="a")
@pytest.mark.xdist_group(name="aa")
def test_1():
pass
"""
pytester.makepyfile(test_a=test_file, test_b=test_file)
result = pytester.runpytest("-n2", "--dist=loadgroup", "-v")
res = parse_tests_and_workers_from_output(result.outlines)
assert len(res) == 2
# get test names
a_1 = next(t[2] for t in res if "test_a.py::test_1" in t[2])
b_1 = next(t[2] for t in res if "test_b.py::test_1" in t[2])
# check groups, order should be sorted
assert a_1.split("@")[1] == b_1.split("@")[1] == "a_aa_b_c_c2_d"
class TestLocking: class TestLocking:
_test_content = """ _test_content = """
@@ -1450,18 +1568,20 @@ class TestLocking:
FILE_LOCK = filelock.FileLock("test.lock") FILE_LOCK = filelock.FileLock("test.lock")
""" + ( """ + ((_test_content * 4) % ("A", "B", "C", "D"))
(_test_content * 4) % ("A", "B", "C", "D")
)
@pytest.mark.parametrize("scope", ["each", "load", "loadscope", "loadfile", "no"]) @pytest.mark.parametrize(
def test_single_file(self, pytester, scope) -> None: "scope", ["each", "load", "loadscope", "loadfile", "worksteal", "no"]
)
def test_single_file(self, pytester: pytest.Pytester, scope: str) -> None:
pytester.makepyfile(test_a=self.test_file1) pytester.makepyfile(test_a=self.test_file1)
result = pytester.runpytest("-n2", "--dist=%s" % scope, "-v") result = pytester.runpytest("-n2", "--dist=%s" % scope, "-v")
result.assert_outcomes(passed=(12 if scope != "each" else 12 * 2)) result.assert_outcomes(passed=(12 if scope != "each" else 12 * 2))
@pytest.mark.parametrize("scope", ["each", "load", "loadscope", "loadfile", "no"]) @pytest.mark.parametrize(
def test_multi_file(self, pytester, scope) -> None: "scope", ["each", "load", "loadscope", "loadfile", "worksteal", "no"]
)
def test_multi_file(self, pytester: pytest.Pytester, scope: str) -> None:
pytester.makepyfile( pytester.makepyfile(
test_a=self.test_file1, test_a=self.test_file1,
test_b=self.test_file1, test_b=self.test_file1,
@@ -1472,7 +1592,7 @@ class TestLocking:
result.assert_outcomes(passed=(48 if scope != "each" else 48 * 2)) result.assert_outcomes(passed=(48 if scope != "each" else 48 * 2))
def parse_tests_and_workers_from_output(lines: List[str]) -> List[Tuple[str, str, str]]: def parse_tests_and_workers_from_output(lines: list[str]) -> list[tuple[str, str, str]]:
result = [] result = []
for line in lines: for line in lines:
# example match: "[gw0] PASSED test_a.py::test[7]" # example match: "[gw0] PASSED test_a.py::test[7]"
@@ -1494,9 +1614,9 @@ def parse_tests_and_workers_from_output(lines: List[str]) -> List[Tuple[str, str
def get_workers_and_test_count_by_prefix( def get_workers_and_test_count_by_prefix(
prefix: str, lines: List[str], expected_status: str = "PASSED" prefix: str, lines: list[str], expected_status: str = "PASSED"
) -> Dict[str, int]: ) -> dict[str, int]:
result: Dict[str, int] = {} result: dict[str, int] = {}
for worker, status, nodeid in parse_tests_and_workers_from_output(lines): for worker, status, nodeid in parse_tests_and_workers_from_output(lines):
if expected_status == status and nodeid.startswith(prefix): if expected_status == status and nodeid.startswith(prefix):
result[worker] = result.get(worker, 0) + 1 result[worker] = result.get(worker, 0) + 1
@@ -1505,32 +1625,32 @@ def get_workers_and_test_count_by_prefix(
class TestAPI: class TestAPI:
@pytest.fixture @pytest.fixture
def fake_request(self): def fake_request(self) -> pytest.FixtureRequest:
class FakeOption: class FakeOption:
def __init__(self): def __init__(self) -> None:
self.dist = "load" self.dist = "load"
class FakeConfig: class FakeConfig:
def __init__(self): def __init__(self) -> None:
self.workerinput = {"workerid": "gw5"} self.workerinput = {"workerid": "gw5"}
self.option = FakeOption() self.option = FakeOption()
class FakeRequest: class FakeRequest:
def __init__(self): def __init__(self) -> None:
self.config = FakeConfig() self.config = FakeConfig()
return FakeRequest() return cast(pytest.FixtureRequest, FakeRequest())
def test_is_xdist_worker(self, fake_request) -> None: def test_is_xdist_worker(self, fake_request: pytest.FixtureRequest) -> None:
assert xdist.is_xdist_worker(fake_request) assert xdist.is_xdist_worker(fake_request)
del fake_request.config.workerinput del fake_request.config.workerinput # type: ignore[attr-defined]
assert not xdist.is_xdist_worker(fake_request) assert not xdist.is_xdist_worker(fake_request)
def test_is_xdist_controller(self, fake_request) -> None: def test_is_xdist_controller(self, fake_request: pytest.FixtureRequest) -> None:
assert not xdist.is_xdist_master(fake_request) assert not xdist.is_xdist_master(fake_request)
assert not xdist.is_xdist_controller(fake_request) assert not xdist.is_xdist_controller(fake_request)
del fake_request.config.workerinput del fake_request.config.workerinput # type: ignore[attr-defined]
assert xdist.is_xdist_master(fake_request) assert xdist.is_xdist_master(fake_request)
assert xdist.is_xdist_controller(fake_request) assert xdist.is_xdist_controller(fake_request)
@@ -1538,26 +1658,44 @@ class TestAPI:
assert not xdist.is_xdist_master(fake_request) assert not xdist.is_xdist_master(fake_request)
assert not xdist.is_xdist_controller(fake_request) assert not xdist.is_xdist_controller(fake_request)
def test_get_xdist_worker_id(self, fake_request) -> None: def test_get_xdist_worker_id(self, fake_request: pytest.FixtureRequest) -> None:
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 # type: ignore[attr-defined]
assert xdist.get_xdist_worker_id(fake_request) == "master" assert xdist.get_xdist_worker_id(fake_request) == "master"
def test_collection_crash(testdir): def test_collection_crash(pytester: pytest.Pytester) -> None:
p1 = testdir.makepyfile( p1 = pytester.makepyfile(
""" """
assert 0 assert 0
""" """
) )
result = testdir.runpytest(p1, "-n1") result = pytester.runpytest(p1, "-n1")
assert result.ret == 1 assert result.ret == 1
result.stdout.fnmatch_lines( result.stdout.fnmatch_lines(
[ [
"gw0 I", "created: 1/1 worker",
"gw0 [[]0[]]", "1 worker [[]0 items[]]",
"*_ ERROR collecting test_collection_crash.py _*", "*_ ERROR collecting test_collection_crash.py _*",
"E assert 0", "E assert 0",
"*= 1 error in *", "*= 1 error in *",
] ]
) )
def test_dist_in_addopts(pytester: pytest.Pytester) -> None:
"""Users can set a default distribution in the configuration file (#789)."""
pytester.makepyfile(
"""
def test():
pass
"""
)
pytester.makeini(
"""
[pytest]
addopts = --dist loadscope
"""
)
result = pytester.runpytest()
assert result.ret == 0

View File

@@ -1,18 +1,25 @@
from __future__ import annotations
from collections.abc import Generator
import shutil
from typing import Callable
import execnet import execnet
import pytest import pytest
import shutil
from typing import List
pytest_plugins = "pytester" pytest_plugins = "pytester"
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _divert_atexit(request, monkeypatch: pytest.MonkeyPatch): def _divert_atexit(monkeypatch: pytest.MonkeyPatch) -> Generator[None]:
import atexit import atexit
finalizers = [] finalizers = []
def fake_register(func, *args, **kwargs): def fake_register(
func: Callable[..., object], *args: object, **kwargs: object
) -> None:
finalizers.append((func, args, kwargs)) finalizers.append((func, args, kwargs))
monkeypatch.setattr(atexit, "register", fake_register) monkeypatch.setattr(atexit, "register", fake_register)
@@ -24,7 +31,7 @@ def _divert_atexit(request, monkeypatch: pytest.MonkeyPatch):
func(*args, **kwargs) func(*args, **kwargs)
def pytest_addoption(parser) -> None: def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption( parser.addoption(
"--gx", "--gx",
action="append", action="append",
@@ -34,16 +41,16 @@ def pytest_addoption(parser) -> None:
@pytest.fixture @pytest.fixture
def specssh(request) -> str: def specssh(request: pytest.FixtureRequest) -> str:
return getspecssh(request.config) return getspecssh(request.config)
# configuration information for tests # configuration information for tests
def getgspecs(config) -> List[execnet.XSpec]: def getgspecs(config: pytest.Config) -> list[execnet.XSpec]:
return [execnet.XSpec(spec) for spec in config.getvalueorskip("gspecs")] return [execnet.XSpec(spec) for spec in config.getvalueorskip("gspecs")]
def getspecssh(config) -> str: # type: ignore[return] def getspecssh(config: pytest.Config) -> str:
xspecs = getgspecs(config) xspecs = getgspecs(config)
for spec in xspecs: for spec in xspecs:
if spec.ssh: if spec.ssh:
@@ -53,7 +60,7 @@ def getspecssh(config) -> str: # type: ignore[return]
pytest.skip("need '--gx ssh=...'") pytest.skip("need '--gx ssh=...'")
def getsocketspec(config) -> execnet.XSpec: def getsocketspec(config: pytest.Config) -> execnet.XSpec:
xspecs = getgspecs(config) xspecs = getgspecs(config)
for spec in xspecs: for spec in xspecs:
if spec.socket: if spec.socket:

View File

@@ -1,31 +1,55 @@
from xdist.dsession import DSession, get_default_max_worker_restart from __future__ import annotations
from xdist.report import report_collection_diff
from xdist.scheduler import EachScheduling, LoadScheduling from collections.abc import Sequence
from typing import Optional from typing import Any
from typing import cast
from typing import TYPE_CHECKING
import pytest
import execnet import execnet
import pytest
from xdist.dsession import DSession
from xdist.dsession import get_default_max_worker_restart
from xdist.dsession import get_workers_status_line
from xdist.dsession import WorkerStatus
from xdist.report import report_collection_diff
from xdist.scheduler import EachScheduling
from xdist.scheduler import LoadScheduling
from xdist.scheduler import WorkStealingScheduling
from xdist.workermanage import WorkerController
class MockGateway: if TYPE_CHECKING:
BaseOfMockGateway = execnet.Gateway
BaseOfMockNode = WorkerController
else:
BaseOfMockGateway = object
BaseOfMockNode = object
class MockGateway(BaseOfMockGateway):
def __init__(self) -> None: def __init__(self) -> None:
self._count = 0 self._count = 0
self.id = str(self._count) self.id = str(self._count)
self._count += 1 self._count += 1
class MockNode: class MockNode(BaseOfMockNode):
def __init__(self) -> None: def __init__(self) -> None:
self.sent = [] # type: ignore[var-annotated] self.sent: list[int | str] = []
self.stolen: list[int] = []
self.gateway = MockGateway() self.gateway = MockGateway()
self._shutdown = False self._shutdown = False
def send_runtest_some(self, indices) -> None: def send_runtest_some(self, indices: Sequence[int]) -> None:
self.sent.extend(indices) self.sent.extend(indices)
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: Sequence[int]) -> None:
self.stolen.extend(indices)
def shutdown(self) -> None: def shutdown(self) -> None:
self._shutdown = True self._shutdown = True
@@ -36,10 +60,9 @@ class MockNode:
class TestEachScheduling: class TestEachScheduling:
def test_schedule_load_simple(self, pytester: pytest.Pytester) -> None: def test_schedule_load_simple(self, pytester: pytest.Pytester) -> None:
node1 = MockNode()
node2 = MockNode()
config = pytester.parseconfig("--tx=2*popen") config = pytester.parseconfig("--tx=2*popen")
sched = EachScheduling(config) sched = EachScheduling(config)
node1, node2 = MockNode(), MockNode()
sched.add_node(node1) sched.add_node(node1)
sched.add_node(node2) sched.add_node(node2)
collection = ["a.py::test_1"] collection = ["a.py::test_1"]
@@ -47,7 +70,7 @@ class TestEachScheduling:
sched.add_node_collection(node1, collection) sched.add_node_collection(node1, collection)
assert not sched.collection_is_completed assert not sched.collection_is_completed
sched.add_node_collection(node2, collection) sched.add_node_collection(node2, collection)
assert sched.collection_is_completed assert bool(sched.collection_is_completed)
assert sched.node2collection[node1] == collection assert sched.node2collection[node1] == collection
assert sched.node2collection[node2] == collection assert sched.node2collection[node2] == collection
sched.schedule() sched.schedule()
@@ -60,14 +83,14 @@ class TestEachScheduling:
assert sched.tests_finished assert sched.tests_finished
def test_schedule_remove_node(self, pytester: pytest.Pytester) -> None: def test_schedule_remove_node(self, pytester: pytest.Pytester) -> None:
node1 = MockNode()
config = pytester.parseconfig("--tx=popen") config = pytester.parseconfig("--tx=popen")
sched = EachScheduling(config) sched = EachScheduling(config)
node1 = MockNode()
sched.add_node(node1) sched.add_node(node1)
collection = ["a.py::test_1"] collection = ["a.py::test_1"]
assert not sched.collection_is_completed assert not sched.collection_is_completed
sched.add_node_collection(node1, collection) sched.add_node_collection(node1, collection)
assert sched.collection_is_completed assert bool(sched.collection_is_completed)
assert sched.node2collection[node1] == collection assert sched.node2collection[node1] == collection
sched.schedule() sched.schedule()
assert sched.tests_finished assert sched.tests_finished
@@ -81,15 +104,15 @@ class TestLoadScheduling:
def test_schedule_load_simple(self, pytester: pytest.Pytester) -> None: def test_schedule_load_simple(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()) node1, node2 = MockNode(), MockNode()
sched.add_node(MockNode()) sched.add_node(node1)
node1, node2 = sched.nodes sched.add_node(node2)
collection = ["a.py::test_1", "a.py::test_2"] collection = ["a.py::test_1", "a.py::test_2"]
assert not sched.collection_is_completed assert not sched.collection_is_completed
sched.add_node_collection(node1, collection) sched.add_node_collection(node1, collection)
assert not sched.collection_is_completed assert not sched.collection_is_completed
sched.add_node_collection(node2, collection) sched.add_node_collection(node2, collection)
assert sched.collection_is_completed assert bool(sched.collection_is_completed)
assert sched.node2collection[node1] == collection assert sched.node2collection[node1] == collection
assert sched.node2collection[node2] == collection assert sched.node2collection[node2] == collection
sched.schedule() sched.schedule()
@@ -99,15 +122,17 @@ class TestLoadScheduling:
assert len(node2.sent) == 1 assert len(node2.sent) == 1
assert node1.sent == [0] assert node1.sent == [0]
assert node2.sent == [1] assert node2.sent == [1]
sched.mark_test_complete(node1, node1.sent[0]) sent10 = node1.sent[0]
assert isinstance(sent10, int)
sched.mark_test_complete(node1, sent10)
assert sched.tests_finished assert sched.tests_finished
def test_schedule_batch_size(self, pytester: pytest.Pytester) -> None: def test_schedule_batch_size(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()) node1, node2 = MockNode(), MockNode()
sched.add_node(MockNode()) sched.add_node(node1)
node1, node2 = sched.nodes sched.add_node(node2)
col = ["xyz"] * 6 col = ["xyz"] * 6
sched.add_node_collection(node1, col) sched.add_node_collection(node1, col)
sched.add_node_collection(node2, col) sched.add_node_collection(node2, col)
@@ -129,52 +154,102 @@ class TestLoadScheduling:
assert node1.sent == [0, 1, 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()) node1, node2 = MockNode(), MockNode()
sched.add_node(MockNode()) sched.add_node(node1)
sched.add_node(MockNode()) sched.add_node(node2)
node1, node2, node3 = sched.nodes col = [f"test{i}" for i in range(16)]
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.schedule() 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, *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)
node1, node2 = MockNode(), MockNode()
sched.add_node(node1)
sched.add_node(node2)
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)):
sent_index = node1.sent[complete_index]
assert isinstance(sent_index, int)
sched.mark_test_complete(node1, sent_index)
assert node1.sent == [0, 1, *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)
node1, node2, node3 = MockNode(), MockNode(), MockNode()
sched.add_node(node1)
sched.add_node(node2)
sched.add_node(node3)
col = ["xyz"] * 2
sched.add_node_collection(node1, col)
sched.add_node_collection(node2, col)
sched.add_node_collection(node3, col)
assert sched.collection_is_completed
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()) node1, node2, node3 = MockNode(), MockNode(), MockNode()
sched.add_node(MockNode()) sched.add_node(node1)
sched.add_node(MockNode()) sched.add_node(node2)
node1, node2, node3 = sched.nodes sched.add_node(node3)
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:
node = MockNode()
config = pytester.parseconfig("--tx=popen") config = pytester.parseconfig("--tx=popen")
sched = LoadScheduling(config) sched = LoadScheduling(config)
node = MockNode()
sched.add_node(node) sched.add_node(node)
collection = ["test_file.py::test_func"] collection = ["test_file.py::test_func"]
sched.add_node_collection(node, collection) sched.add_node_collection(node, collection)
@@ -191,22 +266,19 @@ class TestLoadScheduling:
""" """
class CollectHook: class CollectHook:
""" """Dummy hook that stores collection reports."""
Dummy hook that stores collection reports.
"""
def __init__(self): def __init__(self) -> None:
self.reports = [] self.reports: list[pytest.CollectReport] = []
def pytest_collectreport(self, report): def pytest_collectreport(self, report: pytest.CollectReport) -> None:
self.reports.append(report) self.reports.append(report)
collect_hook = CollectHook() collect_hook = CollectHook()
config = pytester.parseconfig("--tx=2*popen") config = pytester.parseconfig("--tx=2*popen")
config.pluginmanager.register(collect_hook, "collect_hook") config.pluginmanager.register(collect_hook, "collect_hook")
node1 = MockNode()
node2 = MockNode()
sched = LoadScheduling(config) sched = LoadScheduling(config)
node1, node2 = MockNode(), MockNode()
sched.add_node(node1) sched.add_node(node1)
sched.add_node(node2) sched.add_node(node2)
sched.add_node_collection(node1, ["a.py::test_1"]) sched.add_node_collection(node1, ["a.py::test_1"])
@@ -214,14 +286,192 @@ class TestLoadScheduling:
sched.schedule() sched.schedule()
assert len(collect_hook.reports) == 1 assert len(collect_hook.reports) == 1
rep = collect_hook.reports[0] rep = collect_hook.reports[0]
assert isinstance(rep.longrepr, str)
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)
node1, node2 = MockNode(), MockNode()
sched.add_node(node1)
sched.add_node(node2)
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 bool(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(8))
assert node2.sent == list(range(8, 16))
for i in range(8):
sent1, sent2 = node1.sent[i], node2.sent[i]
assert isinstance(sent1, int) and isinstance(sent2, int)
sched.mark_test_complete(node1, sent1)
sched.mark_test_complete(node2, sent2)
assert bool(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)
node1, node2 = MockNode(), MockNode()
sched.add_node(node1)
sched.add_node(node2)
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(8))
assert node2.sent == list(range(8, 16))
for i in range(8):
sent = node1.sent[i]
assert isinstance(sent, int)
sched.mark_test_complete(node1, sent)
assert node2.stolen == list(range(12, 16))
sched.remove_pending_tests_from_node(node2, node2.stolen)
for i in range(4):
sent = node2.sent[i]
assert isinstance(sent, int)
sched.mark_test_complete(node2, sent)
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)
node1, node2, node3 = MockNode(), MockNode(), MockNode()
sched.add_node(node1)
sched.add_node(node2)
sched.add_node(node3)
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)
node1, node2, node3 = MockNode(), MockNode(), MockNode()
sched.add_node(node1)
sched.add_node(node2)
sched.add_node(node3)
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
sent10 = node1.sent[0]
assert isinstance(sent10, int)
sent20 = node2.sent[0]
assert isinstance(sent20, int)
sent30 = node3.sent[0]
assert isinstance(sent30, int)
sent31 = node3.sent[1]
assert isinstance(sent31, int)
sched.mark_test_complete(node1, sent10)
sched.mark_test_complete(node2, sent20)
sched.mark_test_complete(node3, sent30)
sched.mark_test_complete(node3, sent31)
assert bool(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) -> None:
self.reports: list[pytest.CollectReport] = []
def pytest_collectreport(self, report: pytest.CollectReport) -> None:
self.reports.append(report)
collect_hook = CollectHook()
config = pytester.parseconfig("--tx=2*popen")
config.pluginmanager.register(collect_hook, "collect_hook")
sched = WorkStealingScheduling(config)
node1, node2 = MockNode(), MockNode()
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 isinstance(rep.longrepr, str)
assert "Different tests were collected between" in rep.longrepr 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: Any) -> None:
config = pytester.parseconfig() config = pytester.parseconfig()
from _pytest.pytest_terminal import TerminalReporter from _pytest.terminal import TerminalReporter
rep = TerminalReporter(config, file=linecomp.stringio) rep = TerminalReporter(config, file=linecomp.stringio)
config.pluginmanager.register(rep, "terminalreporter") config.pluginmanager.register(rep, "terminalreporter")
@@ -252,15 +502,17 @@ class TestDistReporter:
def test_report_collection_diff_equal() -> None: def test_report_collection_diff_equal() -> None:
"""Test reporting of equal collections.""" """Test reporting of equal collections."""
from_collection = to_collection = ["aaa", "bbb", "ccc"] from_collection = to_collection = ["aaa", "bbb", "ccc"]
assert report_collection_diff(from_collection, to_collection, 1, 2) is None assert report_collection_diff(from_collection, to_collection, "1", "2") is None
def test_default_max_worker_restart() -> None: def test_default_max_worker_restart() -> None:
class config: class MockConfig:
class option: class option:
maxworkerrestart: Optional[str] = None maxworkerrestart: str | None = None
numprocesses: int = 0 numprocesses: int = 0
config = cast(pytest.Config, MockConfig)
assert get_default_max_worker_restart(config) is None assert get_default_max_worker_restart(config) is None
config.option.numprocesses = 2 config.option.numprocesses = 2
@@ -291,7 +543,8 @@ def test_report_collection_diff_different() -> None:
"+XXX\n" "+XXX\n"
" ccc\n" " ccc\n"
"-YYY\n" "-YYY\n"
"To see why this happens see Known limitations in documentation" "To see why this happens see 'Known limitations' in documentation "
"for pytest-xdist"
) )
msg = report_collection_diff(from_collection, to_collection, "1", "2") msg = report_collection_diff(from_collection, to_collection, "1", "2")
@@ -312,3 +565,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,18 +1,18 @@
from __future__ import annotations
import pathlib
from pathlib import Path
import shutil
import tempfile
import textwrap
import unittest.mock import unittest.mock
from typing import List
import pytest import pytest
import shutil
import textwrap
from pathlib import Path
from xdist.looponfail import RemoteControl from xdist.looponfail import RemoteControl
from xdist.looponfail import StatRecorder from xdist.looponfail import StatRecorder
PYTEST_GTE_7 = hasattr(pytest, "version_tuple") and pytest.version_tuple >= (7, 0) # type: ignore[attr-defined]
class TestStatRecorder: class TestStatRecorder:
def test_filechange(self, tmp_path: Path) -> None: def test_filechange(self, tmp_path: Path) -> None:
tmp = tmp_path tmp = tmp_path
@@ -76,7 +76,7 @@ class TestStatRecorder:
# 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
dirname = str(tmp) dirname = str(tmp)
dirnames: List[str] = [] dirnames: list[str] = []
filenames = [str(p)] filenames = [str(p)]
with unittest.mock.patch( with unittest.mock.patch(
"os.walk", return_value=[(dirname, dirnames, filenames)], autospec=True "os.walk", return_value=[(dirname, dirnames, filenames)], autospec=True
@@ -123,12 +123,11 @@ class TestRemoteControl:
item = pytester.getitem("def test_func():\n assert 0\n") item = pytester.getitem("def test_func():\n assert 0\n")
control = RemoteControl(item.config) control = RemoteControl(item.config)
control.setup() control.setup()
failures = control.runsession() failures = control.runsession()[0]
assert failures assert failures
control.setup() control.setup()
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]
assert not failures assert not failures
@@ -144,10 +143,7 @@ class TestRemoteControl:
control = RemoteControl(modcol.config) control = RemoteControl(modcol.config)
control.loop_once() control.loop_once()
assert control.failures assert control.failures
if PYTEST_GTE_7: modcol_path = modcol.path
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(
@@ -177,10 +173,7 @@ class TestRemoteControl:
""" """
) )
) )
if PYTEST_GTE_7: parent = modcol.path.parent.parent
parent = modcol.path.parent.parent # type: ignore[attr-defined]
else:
parent = Path(modcol.fspath.dirpath().dirpath())
monkeypatch.chdir(parent) monkeypatch.chdir(parent)
modcol.config.args = [ modcol.config.args = [
str(Path(x).relative_to(parent)) for x in modcol.config.args str(Path(x).relative_to(parent)) for x in modcol.config.args
@@ -191,6 +184,43 @@ class TestRemoteControl:
control.loop_once() control.loop_once()
assert control.failures assert control.failures
def test_ignore_sys_path_hook_entry(
self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
) -> None:
# Modifying sys.path as seen by the worker process is a bit tricky,
# because any changes made in the current process do not carry over.
# However, we can leverage the `sitecustomize` behavior to run arbitrary
# code when the subprocess interpreter is starting up. We just need to
# install our module in the search path, which we can accomplish by
# adding a temporary directory to PYTHONPATH.
tmpdir = tempfile.TemporaryDirectory()
with open(pathlib.Path(tmpdir.name) / "sitecustomize.py", "w") as custom:
print(
textwrap.dedent(
"""
import sys
sys.path.append('dummy.__path_hook__')
"""
),
file=custom,
)
monkeypatch.setenv("PYTHONPATH", tmpdir.name, prepend=":")
item = pytester.getitem(
textwrap.dedent(
"""
def test_func():
import sys
assert "dummy.__path_hook__" in sys.path
"""
)
)
control = RemoteControl(item.config)
control.setup()
topdir, failures = control.runsession()[:2]
assert not failures
class TestLooponFailing: class TestLooponFailing:
def test_looponfail_from_fail_to_ok(self, pytester: pytest.Pytester) -> None: def test_looponfail_from_fail_to_ok(self, pytester: pytest.Pytester) -> None:
@@ -209,8 +239,7 @@ class TestLooponFailing:
remotecontrol.loop_once() remotecontrol.loop_once()
assert len(remotecontrol.failures) == 1 assert len(remotecontrol.failures) == 1
modcol_path = modcol.path if PYTEST_GTE_7 else Path(modcol.fspath) modcol.path.write_text(
modcol_path.write_text(
textwrap.dedent( textwrap.dedent(
""" """
def test_one(): def test_one():
@@ -220,7 +249,7 @@ class TestLooponFailing:
""" """
) )
) )
removepyc(modcol_path) removepyc(modcol.path)
remotecontrol.loop_once() remotecontrol.loop_once()
assert not remotecontrol.failures assert not remotecontrol.failures
@@ -238,8 +267,7 @@ class TestLooponFailing:
assert len(remotecontrol.failures) == 1 assert len(remotecontrol.failures) == 1
assert "test_one" in remotecontrol.failures[0] assert "test_one" in remotecontrol.failures[0]
modcol_path = modcol.path if PYTEST_GTE_7 else Path(modcol.fspath) modcol.path.write_text(
modcol_path.write_text(
textwrap.dedent( textwrap.dedent(
""" """
def test_one(): def test_one():
@@ -249,7 +277,7 @@ class TestLooponFailing:
""" """
) )
) )
removepyc(modcol_path) removepyc(modcol.path)
remotecontrol.loop_once() remotecontrol.loop_once()
assert len(remotecontrol.failures) == 0 assert len(remotecontrol.failures) == 0
remotecontrol.loop_once() remotecontrol.loop_once()
@@ -304,7 +332,7 @@ class TestLooponFailing:
remotecontrol = RemoteControl(modcol.config) remotecontrol = RemoteControl(modcol.config)
orig_runsession = remotecontrol.runsession orig_runsession = remotecontrol.runsession
def runsession_dups(): def runsession_dups() -> tuple[list[str], list[str], bool]:
# twisted.trial test cases may report multiple errors. # twisted.trial test cases may report multiple errors.
failures, reports, collection_failed = orig_runsession() failures, reports, collection_failed = orig_runsession()
print(failures) print(failures)

View File

@@ -14,9 +14,9 @@ class TestHooks:
) )
def test_runtest_logreport(self, pytester: pytest.Pytester) -> None: def test_runtest_logreport(self, pytester: pytest.Pytester) -> None:
"""Test that log reports from pytest_runtest_logreport when running """Test that log reports from pytest_runtest_logreport when running with
with xdist contain "node", "nodeid", "worker_id", and "testrun_uid" attributes. (#8) xdist contain "node", "nodeid", "worker_id", and "testrun_uid"
""" attributes (#8)."""
pytester.makeconftest( pytester.makeconftest(
""" """
def pytest_runtest_logreport(report): def pytest_runtest_logreport(report):

View File

@@ -1,18 +1,19 @@
from contextlib import suppress from contextlib import suppress
import os
from pathlib import Path from pathlib import Path
import sys import sys
import os
import execnet import execnet
from xdist.workermanage import NodeManager
import pytest import pytest
from xdist.workermanage import NodeManager
@pytest.fixture @pytest.fixture
def monkeypatch_3_cpus(monkeypatch: pytest.MonkeyPatch): def monkeypatch_3_cpus(monkeypatch: pytest.MonkeyPatch) -> None:
"""Make pytest-xdist believe the system has 3 CPUs""" """Make pytest-xdist believe the system has 3 CPUs."""
monkeypatch.setitem(sys.modules, "psutil", None) # block import # block import
monkeypatch.setitem(sys.modules, "psutil", None)
monkeypatch.delattr(os, "sched_getaffinity", raising=False) monkeypatch.delattr(os, "sched_getaffinity", raising=False)
monkeypatch.setattr(os, "cpu_count", lambda: 3) monkeypatch.setattr(os, "cpu_count", lambda: 3)
@@ -47,12 +48,31 @@ def test_dist_options(pytester: pytest.Pytester) -> None:
check_options(config) check_options(config)
assert config.option.dist == "load" assert config.option.dist == "load"
config = pytester.parseconfigure("--numprocesses", "0")
check_options(config)
assert config.option.dist == "no"
assert config.option.tx == []
config = pytester.parseconfigure("--numprocesses", "0", "-d")
check_options(config)
assert config.option.dist == "no"
assert config.option.tx == []
config = pytester.parseconfigure(
"--numprocesses", "0", "--dist", "each", "--tx", "2*popen"
)
check_options(config)
assert config.option.dist == "no"
assert config.option.tx == []
def test_auto_detect_cpus( def test_auto_detect_cpus(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
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
@@ -74,13 +94,8 @@ def test_auto_detect_cpus(
check_options(config) check_options(config)
assert config.getoption("numprocesses") == 99 assert config.getoption("numprocesses") == 99
config = pytester.parseconfigure("-nauto", "--pdb") for numprocesses in (0, "auto", "logical"):
check_options(config) config = pytester.parseconfigure(f"-n{numprocesses}", "--pdb")
assert config.getoption("usepdb")
assert config.getoption("numprocesses") == 0
assert config.getoption("dist") == "no"
config = pytester.parseconfigure("-nlogical", "--pdb")
check_options(config) check_options(config)
assert config.getoption("usepdb") assert config.getoption("usepdb")
assert config.getoption("numprocesses") == 0 assert config.getoption("numprocesses") == 0
@@ -100,6 +115,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")
@@ -112,10 +128,12 @@ def test_auto_detect_cpus_psutil(
def test_auto_detect_cpus_os( def test_auto_detect_cpus_os(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, monkeypatch_3_cpus pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, monkeypatch_3_cpus: None
) -> None: ) -> None:
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)
config = pytester.parseconfigure("-nauto") config = pytester.parseconfigure("-nauto")
check_options(config) check_options(config)
assert config.getoption("numprocesses") == 3 assert config.getoption("numprocesses") == 3
@@ -171,12 +189,14 @@ def test_hook_auto_num_workers_arg(
def test_hook_auto_num_workers_none( def test_hook_auto_num_workers_none(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, monkeypatch_3_cpus pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, monkeypatch_3_cpus: None
) -> None: ) -> None:
# Returning None from a hook to skip it is pytest behavior, # Returning None from a hook to skip it is pytest behavior,
# but we document it so let's test it. # 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
monkeypatch.delenv("PYTEST_XDIST_AUTO_NUM_WORKERS", raising=False)
pytester.makeconftest( pytester.makeconftest(
""" """
def pytest_xdist_auto_num_workers(): def pytest_xdist_auto_num_workers():
@@ -211,7 +231,7 @@ def test_envvar_auto_num_workers(
def test_envvar_auto_num_workers_warn( def test_envvar_auto_num_workers_warn(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, monkeypatch_3_cpus pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, monkeypatch_3_cpus: None
) -> None: ) -> None:
from xdist.plugin import pytest_cmdline_main as check_options from xdist.plugin import pytest_cmdline_main as check_options
@@ -224,7 +244,7 @@ def test_envvar_auto_num_workers_warn(
def test_auto_num_workers_hook_overrides_envvar( def test_auto_num_workers_hook_overrides_envvar(
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, monkeypatch_3_cpus pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch, monkeypatch_3_cpus: None
) -> None: ) -> None:
from xdist.plugin import pytest_cmdline_main as check_options from xdist.plugin import pytest_cmdline_main as check_options
@@ -275,7 +295,7 @@ class TestDistOptions:
def test_getxspecs(self, pytester: pytest.Pytester) -> None: def test_getxspecs(self, pytester: pytest.Pytester) -> None:
config = pytester.parseconfigure("--tx=popen", "--tx", "ssh=xyz") config = pytester.parseconfigure("--tx=popen", "--tx", "ssh=xyz")
nodemanager = NodeManager(config) nodemanager = NodeManager(config)
xspecs = nodemanager._getxspecs() xspecs = nodemanager._gettxspecs()
assert len(xspecs) == 2 assert len(xspecs) == 2
print(xspecs) print(xspecs)
assert xspecs[0].popen assert xspecs[0].popen
@@ -283,7 +303,7 @@ class TestDistOptions:
def test_xspecs_multiplied(self, pytester: pytest.Pytester) -> None: def test_xspecs_multiplied(self, pytester: pytest.Pytester) -> None:
config = pytester.parseconfigure("--tx=3*popen") config = pytester.parseconfigure("--tx=3*popen")
xspecs = NodeManager(config)._getxspecs() xspecs = NodeManager(config)._gettxspecs()
assert len(xspecs) == 3 assert len(xspecs) == 3
assert xspecs[1].popen assert xspecs[1].popen

View File

@@ -1,57 +1,73 @@
from __future__ import annotations
import marshal
import pprint import pprint
import pytest from queue import Queue
import sys import sys
from typing import Any
from typing import Callable
from typing import cast
from typing import Union
import uuid import uuid
from xdist.workermanage import WorkerController
import execnet import execnet
import marshal import pytest
from xdist.workermanage import NodeManager
from xdist.workermanage import WorkerController
from queue import Queue
WAIT_TIMEOUT = 10.0 WAIT_TIMEOUT = 10.0
def check_marshallable(d): def check_marshallable(d: object) -> None:
try: try:
marshal.dumps(d) marshal.dumps(d) # type: ignore[arg-type]
except ValueError: except ValueError as e:
pprint.pprint(d) pprint.pprint(d)
raise ValueError("not marshallable") raise ValueError("not marshallable") from e
class EventCall: class EventCall:
def __init__(self, eventcall): def __init__(self, eventcall: tuple[str, dict[str, Any]]) -> None:
self.name, self.kwargs = eventcall self.name, self.kwargs = eventcall
def __str__(self): def __str__(self) -> str:
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: pytest.FixtureRequest, pytester: pytest.Pytester
def __init__(self, request, pytester: pytest.Pytester) -> None: ) -> 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:
self.pytester.chdir() self.pytester.chdir()
# import os ; os.environ['EXECNET_DEBUG'] = "2" # import os ; os.environ['EXECNET_DEBUG'] = "2"
self.gateway = execnet.makegateway() self.gateway = execnet.makegateway("execmodel=main_thread_only//popen")
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
specs = [0, 1] specs = [0, 1]
self.slp = WorkerController(DummyMananger, self.gateway, config, putevent) nodemanager = cast(NodeManager, DummyMananger)
self.slp = WorkerController(
nodemanager=nodemanager,
gateway=self.gateway,
config=config,
putevent=putevent, # type: ignore[arg-type]
)
self.request.addfinalizer(self.slp.ensure_teardown) self.request.addfinalizer(self.slp.ensure_teardown)
self.slp.setup() self.slp.setup()
def popevent(self, name=None): def popevent(self, name: str | None = None) -> EventCall:
while 1: while 1:
if self.use_callback: if self.use_callback:
data = self.events.get(timeout=WAIT_TIMEOUT) data = self.events.get(timeout=WAIT_TIMEOUT)
@@ -60,39 +76,35 @@ 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: str, **kwargs: Any) -> None:
self.slp.sendcommand(name, **kwargs) self.slp.sendcommand(name, **kwargs)
@pytest.fixture @pytest.fixture
def worker(request, pytester: pytest.Pytester) -> WorkerSetup: def worker(request: pytest.FixtureRequest, pytester: pytest.Pytester) -> WorkerSetup:
return WorkerSetup(request, pytester) return WorkerSetup(request, pytester)
@pytest.mark.xfail(reason="#59")
def test_remoteinitconfig(pytester: pytest.Pytester) -> None:
from xdist.remote import remote_initconfig
config1 = pytester.parseconfig()
config2 = remote_initconfig(config1.option.__dict__, config1.args)
assert config2.option.__dict__ == config1.option.__dict__
assert config2.pluginmanager.getplugin("terminal") in (-1, None)
class TestWorkerInteractor: class TestWorkerInteractor:
UnserializerReport = Callable[
[dict[str, Any]], Union[pytest.CollectReport, pytest.TestReport]
]
@pytest.fixture @pytest.fixture
def unserialize_report(self, pytestconfig): def unserialize_report(self, pytestconfig: pytest.Config) -> UnserializerReport:
def unserialize(data): def unserialize(
return pytestconfig.hook.pytest_report_from_serializable( data: dict[str, Any],
) -> pytest.CollectReport | pytest.TestReport:
return pytestconfig.hook.pytest_report_from_serializable( # type: ignore[no-any-return]
config=pytestconfig, data=data config=pytestconfig, data=data
) )
return unserialize return unserialize
def test_basic_collect_and_runtests( def test_basic_collect_and_runtests(
self, worker: WorkerSetup, unserialize_report self, worker: WorkerSetup, unserialize_report: UnserializerReport
) -> None: ) -> None:
worker.pytester.makepyfile( worker.pytester.makepyfile(
""" """
@@ -125,7 +137,9 @@ class TestWorkerInteractor:
ev = worker.popevent("workerfinished") ev = worker.popevent("workerfinished")
assert "workeroutput" in ev.kwargs assert "workeroutput" in ev.kwargs
def test_remote_collect_skip(self, worker: WorkerSetup, unserialize_report) -> None: def test_remote_collect_skip(
self, worker: WorkerSetup, unserialize_report: UnserializerReport
) -> None:
worker.pytester.makepyfile( worker.pytester.makepyfile(
""" """
import pytest import pytest
@@ -139,11 +153,14 @@ class TestWorkerInteractor:
assert ev.name == "collectreport" assert ev.name == "collectreport"
rep = unserialize_report(ev.kwargs["data"]) rep = unserialize_report(ev.kwargs["data"])
assert rep.skipped assert rep.skipped
assert isinstance(rep.longrepr, tuple)
assert rep.longrepr[2] == "Skipped: hello" assert rep.longrepr[2] == "Skipped: hello"
ev = worker.popevent("collectionfinish") ev = worker.popevent("collectionfinish")
assert not ev.kwargs["ids"] assert not ev.kwargs["ids"]
def test_remote_collect_fail(self, worker: WorkerSetup, unserialize_report) -> None: def test_remote_collect_fail(
self, worker: WorkerSetup, unserialize_report: UnserializerReport
) -> None:
worker.pytester.makepyfile("""aasd qwe""") worker.pytester.makepyfile("""aasd qwe""")
worker.setup() worker.setup()
ev = worker.popevent("collectionstart") ev = worker.popevent("collectionstart")
@@ -155,7 +172,9 @@ class TestWorkerInteractor:
ev = worker.popevent("collectionfinish") ev = worker.popevent("collectionfinish")
assert not ev.kwargs["ids"] assert not ev.kwargs["ids"]
def test_runtests_all(self, worker: WorkerSetup, unserialize_report) -> None: def test_runtests_all(
self, worker: WorkerSetup, unserialize_report: UnserializerReport
) -> None:
worker.pytester.makepyfile( worker.pytester.makepyfile(
""" """
def test_func(): pass def test_func(): pass
@@ -174,7 +193,7 @@ class TestWorkerInteractor:
worker.sendcommand("runtests_all") worker.sendcommand("runtests_all")
worker.sendcommand("shutdown") worker.sendcommand("shutdown")
for func in "::test_func", "::test_func2": for func in "::test_func", "::test_func2":
for i in range(3): # setup/call/teardown for _ in range(3): # setup/call/teardown
ev = worker.popevent("testreport") ev = worker.popevent("testreport")
assert ev.name == "testreport" assert ev.name == "testreport"
rep = unserialize_report(ev.kwargs["data"]) rep = unserialize_report(ev.kwargs["data"])
@@ -215,12 +234,107 @@ class TestWorkerInteractor:
) -> None: ) -> None:
worker.use_callback = True worker.use_callback = True
worker.setup() worker.setup()
worker.slp.process_from_remote(("<nonono>", ())) worker.slp.process_from_remote(("<nonono>", {}))
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "INTERNALERROR> ValueError: unknown event: <nonono>" in out assert "INTERNALERROR> ValueError: unknown event: <nonono>" in out
ev = worker.popevent() ev = worker.popevent()
assert ev.name == "errordown" assert ev.name == "errordown"
def test_steal_work(
self, worker: WorkerSetup, unserialize_report: UnserializerReport
) -> 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")
# Cannot steal index 1 because it is completed already, so do not steal any.
assert ev.kwargs["indices"] == []
# Index 2 can be stolen, as it is still pending.
worker.sendcommand("steal", indices=[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: UnserializerReport
) -> 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(
@@ -254,20 +368,16 @@ def test_remote_mainargv(pytester: pytest.Pytester) -> None:
outer_argv = sys.argv outer_argv = sys.argv
pytester.makepyfile( pytester.makepyfile(
""" f"""
def test_mainargv(request): def test_mainargv(request):
assert request.config.workerinput["mainargv"] == {!r} assert request.config.workerinput["mainargv"] == {outer_argv!r}
""".format( """
outer_argv
)
) )
result = pytester.runpytest("-n1") result = pytester.runpytest("-n1")
assert result.ret == 0 assert result.ret == 0
def test_remote_usage_prog(pytester: pytest.Pytester, request) -> None: def test_remote_usage_prog(pytester: pytest.Pytester) -> None:
if not hasattr(request.config._parser, "prog"):
pytest.skip("prog not available in config parser")
pytester.makeconftest( pytester.makeconftest(
""" """
import pytest import pytest

View File

@@ -1,26 +1,35 @@
import execnet from __future__ import annotations
import pytest
from pathlib import Path
import shutil import shutil
import textwrap import textwrap
import warnings import warnings
from pathlib import Path
import execnet
import pytest
from util import generate_warning from util import generate_warning
from xdist import workermanage from xdist import workermanage
from xdist._path import visit_path from xdist._path import visit_path
from xdist.remote import serialize_warning_message from xdist.remote import serialize_warning_message
from xdist.workermanage import HostRSync, NodeManager, unserialize_warning_message from xdist.workermanage import HostRSync
from xdist.workermanage import NodeManager
from xdist.workermanage import unserialize_warning_message
pytest_plugins = "pytester" pytest_plugins = "pytester"
@pytest.fixture @pytest.fixture
def hookrecorder(request, config, pytester: pytest.Pytester): def hookrecorder(
config: pytest.Config, pytester: pytest.Pytester
) -> pytest.HookRecorder:
hookrecorder = pytester.make_hook_recorder(config.pluginmanager) hookrecorder = pytester.make_hook_recorder(config.pluginmanager)
return hookrecorder return hookrecorder
@pytest.fixture @pytest.fixture
def config(pytester: pytest.Pytester): def config(pytester: pytest.Pytester) -> pytest.Config:
return pytester.parseconfig() return pytester.parseconfig()
@@ -39,24 +48,23 @@ def dest(tmp_path: Path) -> Path:
@pytest.fixture @pytest.fixture
def workercontroller(monkeypatch: pytest.MonkeyPatch): def workercontroller(monkeypatch: pytest.MonkeyPatch) -> None:
class MockController: class MockController:
def __init__(self, *args): def __init__(self, *args: object) -> None:
pass pass
def setup(self): def setup(self) -> None:
pass pass
monkeypatch.setattr(workermanage, "WorkerController", MockController) monkeypatch.setattr(workermanage, "WorkerController", MockController)
return MockController
class TestNodeManagerPopen: class TestNodeManagerPopen:
def test_popen_no_default_chdir(self, config) -> None: def test_popen_no_default_chdir(self, config: pytest.Config) -> None:
gm = NodeManager(config, ["popen"]) gm = NodeManager(config, ["popen"])
assert gm.specs[0].chdir is None assert gm.specs[0].chdir is None
def test_default_chdir(self, config) -> None: def test_default_chdir(self, config: pytest.Config) -> None:
specs = ["ssh=noco", "socket=xyz"] specs = ["ssh=noco", "socket=xyz"]
for spec in NodeManager(config, specs).specs: for spec in NodeManager(config, specs).specs:
assert spec.chdir == "pyexecnetcache" assert spec.chdir == "pyexecnetcache"
@@ -64,53 +72,68 @@ class TestNodeManagerPopen:
assert spec.chdir == "abc" assert spec.chdir == "abc"
def test_popen_makegateway_events( def test_popen_makegateway_events(
self, config, hookrecorder, workercontroller self,
config: pytest.Config,
hookrecorder: pytest.HookRecorder,
workercontroller: None,
) -> None: ) -> None:
hm = NodeManager(config, ["popen"] * 2) hm = NodeManager(config, ["popen"] * 2)
hm.setup_nodes(None) hm.setup_nodes(None) # type: ignore[arg-type]
call = hookrecorder.popcall("pytest_xdist_setupnodes") call = hookrecorder.popcall("pytest_xdist_setupnodes")
assert len(call.specs) == 2 assert len(call.specs) == 2
call = hookrecorder.popcall("pytest_xdist_newgateway") # check expected gateways
assert call.gateway.spec == execnet.XSpec("popen") gw_calls = [
assert call.gateway.id == "gw0" hookrecorder.popcall("pytest_xdist_newgateway"),
call = hookrecorder.popcall("pytest_xdist_newgateway") hookrecorder.popcall("pytest_xdist_newgateway"),
assert call.gateway.id == "gw1" ]
assert {c.gateway.id for c in gw_calls} == {"gw0", "gw1"}
for c in gw_calls:
expected_spec = execnet.XSpec(
f"execmodel=main_thread_only//popen//id={c.gateway.id}"
)
assert c.gateway.spec == expected_spec
assert len(hm.group) == 2 assert len(hm.group) == 2
hm.teardown_nodes() hm.teardown_nodes()
assert not len(hm.group) assert not len(hm.group)
def test_popens_rsync( def test_popens_rsync(
self, config, source: Path, dest: Path, workercontroller self,
config: pytest.Config,
source: Path,
dest: Path,
workercontroller: None,
) -> None: ) -> None:
hm = NodeManager(config, ["popen"] * 2) hm = NodeManager(config, ["popen"] * 2)
hm.setup_nodes(None) hm.setup_nodes(None) # type: ignore[arg-type]
assert len(hm.group) == 2 assert len(hm.group) == 2
for gw in hm.group: for gw in hm.group:
class pseudoexec: class pseudoexec:
args = [] # type: ignore[var-annotated] args = [] # type: ignore[var-annotated]
def __init__(self, *args): def __init__(self, *args: object) -> None:
self.args.extend(args) self.args.extend(args)
def waitclose(self): def waitclose(self) -> None:
pass pass
gw.remote_exec = pseudoexec gw.remote_exec = pseudoexec # type: ignore[assignment]
notifications = [] notifications = []
for gw in hm.group: for gw in hm.group:
hm.rsync(gw, source, notify=lambda *args: notifications.append(args)) hm.rsync(gw, source, notify=lambda *args: notifications.append(args))
assert not notifications assert not notifications
hm.teardown_nodes() hm.teardown_nodes()
assert not len(hm.group) assert not len(hm.group)
assert "sys.path.insert" in gw.remote_exec.args[0] assert "sys.path.insert" in gw.remote_exec.args[0] # type: ignore[attr-defined]
def test_rsync_popen_with_path( def test_rsync_popen_with_path(
self, config, source: Path, dest: Path, workercontroller self, config: pytest.Config, source: Path, dest: Path, workercontroller: None
) -> None: ) -> None:
hm = NodeManager(config, ["popen//chdir=%s" % dest] * 1) hm = NodeManager(config, ["popen//chdir=%s" % dest] * 1)
hm.setup_nodes(None) hm.setup_nodes(None) # type: ignore[arg-type]
source.joinpath("dir1", "dir2").mkdir(parents=True) source.joinpath("dir1", "dir2").mkdir(parents=True)
source.joinpath("dir1", "dir2", "hello").touch() source.joinpath("dir1", "dir2", "hello").touch()
notifications = [] notifications = []
@@ -126,15 +149,15 @@ class TestNodeManagerPopen:
def test_rsync_same_popen_twice( def test_rsync_same_popen_twice(
self, self,
config, config: pytest.Config,
source: Path, source: Path,
dest: Path, dest: Path,
hookrecorder, hookrecorder: pytest.HookRecorder,
workercontroller, workercontroller: None,
) -> None: ) -> None:
hm = NodeManager(config, ["popen//chdir=%s" % dest] * 2) hm = NodeManager(config, ["popen//chdir=%s" % dest] * 2)
hm.roots = [] hm.roots = []
hm.setup_nodes(None) hm.setup_nodes(None) # type: ignore[arg-type]
source.joinpath("dir1", "dir2").mkdir(parents=True) source.joinpath("dir1", "dir2").mkdir(parents=True)
source.joinpath("dir1", "dir2", "hello").touch() source.joinpath("dir1", "dir2", "hello").touch()
gw = hm.group[0] gw = hm.group[0]
@@ -162,7 +185,7 @@ class TestHRSync:
assert names == {"dir", "file.txt", "somedir"} assert names == {"dir", "file.txt", "somedir"}
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("execmodel=main_thread_only//popen//chdir=%s" % dest)
finished = [] finished = []
rsync = HostRSync(source) rsync = HostRSync(source)
rsync.add_target_host(gw, finished=lambda: finished.append(1)) rsync.add_target_host(gw, finished=lambda: finished.append(1))
@@ -191,11 +214,15 @@ class TestNodeManager:
p = Path(p) p = Path(p)
print("remote curdir", p) print("remote curdir", p)
assert p == dest.joinpath(config.rootpath.name) assert p == dest.joinpath(config.rootpath.name)
assert p.joinpath("dir1").check() assert p.joinpath("dir1").is_dir()
assert p.joinpath("dir1", "file1").check() assert p.joinpath("dir1", "file1").is_file()
def test_popen_rsync_subdir( def test_popen_rsync_subdir(
self, pytester: pytest.Pytester, source: Path, dest: Path, workercontroller self,
pytester: pytest.Pytester,
source: Path,
dest: Path,
workercontroller: None,
) -> None: ) -> None:
dir1 = source / "dir1" dir1 = source / "dir1"
dir1.mkdir() dir1.mkdir()
@@ -209,7 +236,8 @@ class TestNodeManager:
"--tx", "popen//chdir=%s" % dest, "--rsyncdir", rsyncroot, source "--tx", "popen//chdir=%s" % dest, "--rsyncdir", rsyncroot, source
) )
) )
nodemanager.setup_nodes(None) # calls .rsync_roots() # calls .rsync_roots()
nodemanager.setup_nodes(None) # type: ignore[arg-type]
if rsyncroot == source: if rsyncroot == source:
dest = dest.joinpath("source") dest = dest.joinpath("source")
assert dest.joinpath("dir1").exists() assert dest.joinpath("dir1").exists()
@@ -218,14 +246,19 @@ class TestNodeManager:
nodemanager.teardown_nodes() nodemanager.teardown_nodes()
@pytest.mark.parametrize( @pytest.mark.parametrize(
"flag, expects_report", [("-q", False), ("", False), ("-v", True)] ["flag", "expects_report"],
[
("-q", False),
("", False),
("-v", True),
],
) )
def test_rsync_report( def test_rsync_report(
self, self,
pytester: pytest.Pytester, pytester: pytest.Pytester,
source: Path, source: Path,
dest: Path, dest: Path,
workercontroller, workercontroller: None,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],
flag: str, flag: str,
expects_report: bool, expects_report: bool,
@@ -236,7 +269,8 @@ class TestNodeManager:
if flag: if flag:
args.append(flag) args.append(flag)
nodemanager = NodeManager(pytester.parseconfig(*args)) nodemanager = NodeManager(pytester.parseconfig(*args))
nodemanager.setup_nodes(None) # calls .rsync_roots() # calls .rsync_roots()
nodemanager.setup_nodes(None) # type: ignore[arg-type]
out, _ = capsys.readouterr() out, _ = capsys.readouterr()
if expects_report: if expects_report:
assert "<= pytest/__init__.py" in out assert "<= pytest/__init__.py" in out
@@ -244,7 +278,11 @@ class TestNodeManager:
assert "<= pytest/__init__.py" not in out assert "<= pytest/__init__.py" not in out
def test_init_rsync_roots( def test_init_rsync_roots(
self, pytester: pytest.Pytester, source: Path, dest: Path, workercontroller self,
pytester: pytest.Pytester,
source: Path,
dest: Path,
workercontroller: None,
) -> None: ) -> None:
dir2 = source.joinpath("dir1", "dir2") dir2 = source.joinpath("dir1", "dir2")
dir2.mkdir(parents=True) dir2.mkdir(parents=True)
@@ -262,13 +300,18 @@ class TestNodeManager:
) )
config = pytester.parseconfig(source) config = pytester.parseconfig(source)
nodemanager = NodeManager(config, ["popen//chdir=%s" % dest]) nodemanager = NodeManager(config, ["popen//chdir=%s" % dest])
nodemanager.setup_nodes(None) # calls .rsync_roots() # calls .rsync_roots()
nodemanager.setup_nodes(None) # type: ignore[arg-type]
assert dest.joinpath("dir2").exists() assert dest.joinpath("dir2").exists()
assert not dest.joinpath("dir1").exists() assert not dest.joinpath("dir1").exists()
assert not dest.joinpath("bogus").exists() assert not dest.joinpath("bogus").exists()
def test_rsyncignore( def test_rsyncignore(
self, pytester: pytest.Pytester, source: Path, dest: Path, workercontroller self,
pytester: pytest.Pytester,
source: Path,
dest: Path,
workercontroller: None,
) -> None: ) -> None:
dir2 = source.joinpath("dir1", "dir2") dir2 = source.joinpath("dir1", "dir2")
dir2.mkdir(parents=True) dir2.mkdir(parents=True)
@@ -292,7 +335,8 @@ class TestNodeManager:
config = pytester.parseconfig(source) config = pytester.parseconfig(source)
config.option.rsyncignore = ["bar"] config.option.rsyncignore = ["bar"]
nodemanager = NodeManager(config, ["popen//chdir=%s" % dest]) nodemanager = NodeManager(config, ["popen//chdir=%s" % dest])
nodemanager.setup_nodes(None) # calls .rsync_roots() # calls .rsync_roots()
nodemanager.setup_nodes(None) # type: ignore[arg-type]
assert dest.joinpath("dir1").exists() assert dest.joinpath("dir1").exists()
assert not dest.joinpath("dir1", "dir2").exists() assert not dest.joinpath("dir1", "dir2").exists()
assert dest.joinpath("dir5", "file").exists() assert dest.joinpath("dir5", "file").exists()
@@ -301,14 +345,19 @@ class TestNodeManager:
assert not dest.joinpath("bar").exists() assert not dest.joinpath("bar").exists()
def test_optimise_popen( def test_optimise_popen(
self, pytester: pytest.Pytester, source: Path, dest: Path, workercontroller self,
pytester: pytest.Pytester,
source: Path,
dest: Path,
workercontroller: None,
) -> None: ) -> None:
specs = ["popen"] * 3 specs = ["popen"] * 3
source.joinpath("conftest.py").write_text("rsyncdirs = ['a']") source.joinpath("conftest.py").write_text("rsyncdirs = ['a']")
source.joinpath("a").mkdir() source.joinpath("a").mkdir()
config = pytester.parseconfig(source) config = pytester.parseconfig(source)
nodemanager = NodeManager(config, specs) nodemanager = NodeManager(config, specs)
nodemanager.setup_nodes(None) # calls .rysnc_roots() # calls .rysnc_roots()
nodemanager.setup_nodes(None) # type: ignore[arg-type]
for gwspec in nodemanager.specs: for gwspec in nodemanager.specs:
assert gwspec._samefilesystem() assert gwspec._samefilesystem()
assert not gwspec.chdir assert not gwspec.chdir
@@ -327,6 +376,34 @@ class TestNodeManager:
(rep,) = reprec.getreports("pytest_runtest_logreport") (rep,) = reprec.getreports("pytest_runtest_logreport")
assert rep.passed assert rep.passed
def test_proxy_gateway_setup_nodes(self, pytester: pytest.Pytester) -> None:
nodemanager = NodeManager(
pytester.parseconfig(
"--px", "popen//id=my_proxy", "--tx", "popen//via=my_proxy"
)
)
nodes = nodemanager.setup_nodes(None) # type: ignore[arg-type]
# Proxy gateways are considered as an execnet gateway
assert len(nodemanager.group) == 2
# Proxy gateways do not run workers
assert len(nodes) == 1
def test_proxy_gateway(self, pytester: pytest.Pytester) -> None:
pytester.makepyfile(
__init__="",
test_x="""
def test_one():
pass
""",
)
reprec = pytester.inline_run(
"-d", "--px", "popen//id=my_proxy", "--tx", "popen//via=my_proxy"
)
rep = reprec.getreports("pytest_runtest_logreport")
assert rep[1].passed
class MyWarning(UserWarning): class MyWarning(UserWarning):
pass pass
@@ -344,9 +421,8 @@ class MyWarning(UserWarning):
), ),
], ],
) )
def test_unserialize_warning_msg(w_cls): def test_unserialize_warning_msg(w_cls: type[Warning] | str) -> None:
"""Test that warning serialization process works well""" """Test that warning serialization process works well."""
# Create a test warning message # Create a test warning message
with pytest.warns(UserWarning) as w: with pytest.warns(UserWarning) as w:
if not isinstance(w_cls, str): if not isinstance(w_cls, str):
@@ -374,7 +450,7 @@ def test_unserialize_warning_msg(w_cls):
v1 = getattr(w_msg, k) v1 = getattr(w_msg, k)
v2 = getattr(w_msg2, k) v2 = getattr(w_msg2, k)
if k == "message": if k == "message":
assert type(v1) == type(v2) assert type(v1) is type(v2)
assert v1.args == v2.args assert v1.args == v2.args
else: else:
assert v1 == v2 assert v1 == v2
@@ -386,9 +462,8 @@ class MyWarningUnknown(UserWarning):
__module__ = "unknown" __module__ = "unknown"
def test_warning_serialization_tweaked_module(): def test_warning_serialization_tweaked_module() -> None:
"""Test for GH#404""" """Test for GH#404."""
# Create a test warning message # Create a test warning message
with pytest.warns(UserWarning) as w: with pytest.warns(UserWarning) as w:
warnings.warn("hello", MyWarningUnknown) warnings.warn("hello", MyWarningUnknown)

View File

@@ -5,5 +5,5 @@ class MyWarning2(UserWarning):
pass pass
def generate_warning(): def generate_warning() -> None:
warnings.warn(MyWarning2("hello")) warnings.warn(MyWarning2("hello"))

51
tox.ini
View File

@@ -1,33 +1,26 @@
[tox] [tox]
envlist= envlist =
linting linting
py{36,37,38,39,310}-pytestlatest py{39,310,311,312,313}-pytestlatest
py38-pytestmain py310-pytestmain
py38-psutil py310-psutil
py38-setproctitle py310-setproctitle
isolated_build = true isolated_build = true
[testenv] [testenv]
extras = testing extras =
testing
psutil: psutil
setproctitle: setproctitle
deps = deps =
pytestmin: pytest==7.0.0
pytestlatest: pytest pytestlatest: pytest
pytestmain: git+https://github.com/pytest-dev/pytest.git pytestmain: git+https://github.com/pytest-dev/pytest.git
commands=
pytest {posargs}
[testenv:py38-psutil]
extras =
testing
psutil
commands = commands =
pytest {posargs:-k psutil} pytest {posargs:{env:_XDIST_TOX_DEFAULT_POSARGS:}}
setenv =
[testenv:py38-setproctitle] _XDIST_TOX_DEFAULT_POSARGS={env:_XDIST_TOX_POSARGS_PSUTIL:}
extras = psutil: _XDIST_TOX_POSARGS_PSUTIL=-k psutil
testing
setproctitle
deps = pytest
commands =
pytest {posargs}
[testenv:linting] [testenv:linting]
skip_install = True skip_install = True
@@ -38,9 +31,8 @@ deps =
commands = pre-commit run --all-files --show-diff-on-failure commands = pre-commit run --all-files --show-diff-on-failure
[testenv:release] [testenv:release]
changedir= changedir =
description = do a release, required posarg of the version number description = do a release, required posarg of the version number
basepython = python3.7
skipsdist = True skipsdist = True
usedevelop = True usedevelop = True
passenv = * passenv = *
@@ -50,20 +42,9 @@ commands =
towncrier build --version {posargs} --yes towncrier build --version {posargs} --yes
[testenv:docs] [testenv:docs]
basepython = python3
usedevelop = True usedevelop = True
deps = deps =
sphinx sphinx
sphinx_rtd_theme sphinx_rtd_theme
commands = commands =
sphinx-build -W --keep-going -b html docs docs/_build/html {posargs:} sphinx-build -W --keep-going -b html docs docs/_build/html {posargs:}
[pytest]
# pytest-services also defines a worker_id fixture, disable
# it so they don't conflict with each other (#611).
addopts = -ra -p no:pytest-services
testpaths = testing
[flake8]
max-line-length = 120
ignore = E203,W503