diff --git a/docs/content/docs/testing.mdx b/docs/content/docs/testing.mdx index 505b1a977..e01200a87 100644 --- a/docs/content/docs/testing.mdx +++ b/docs/content/docs/testing.mdx @@ -40,6 +40,11 @@ just test-mpi # build with MPI, then run every leg just test-py-mpi "1;2;4" -m mpi # MPI-marked tests across a rank matrix ``` +The suite's `tests/conftest.py` provides `--with-mpi`; no MPI pytest plugin is +needed. Tests marked with `@pytest.mark.mpi` are skipped without that option. +Use `@pytest.mark.mpi(min_size=2)` when a test needs at least two ranks. +Under `--with-mpi`, only rank zero reports pytest output. + To do it by hand, build with MPI on, then run under `mpiexec` with `--no-sync` so each rank reuses that build. `--reinstall-package` and `--no-cache` force a genuine rebuild, since uv does not key its build cache on the environment: diff --git a/packages/bench-third-party/uv.lock b/packages/bench-third-party/uv.lock index 020029b14..9e2f4e25e 100644 --- a/packages/bench-third-party/uv.lock +++ b/packages/bench-third-party/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12, <3.14" resolution-markers = [ "python_full_version >= '3.13' and sys_platform == 'win32'", @@ -1543,15 +1543,13 @@ bench = [ { name = "psutil", specifier = ">=7.2" }, { name = "pytest", specifier = ">=8.2.2,<9.2" }, { name = "pytest-benchmark", specifier = ">=5" }, - { name = "pytest-cases", specifier = ">=3.8.6,<4" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-datadir" }, - { name = "pytest-mpi", git = "https://github.com/aragilar/pytest-mpi.git?rev=nonzero" }, { name = "tabulate", specifier = ">=0.9" }, ] dev = [ { name = "prek", specifier = ">=0.4" }, - { name = "ruff", specifier = "==0.16.10" }, + { name = "ruff", specifier = "==0.17.0" }, ] docs = [ { name = "griffelib", specifier = ">=2.2,<3" }, @@ -1573,20 +1571,16 @@ interactive = [ test = [ { name = "psutil", specifier = ">=7.2" }, { name = "pytest", specifier = ">=8.2.2,<9.2" }, - { name = "pytest-cases", specifier = ">=3.8.6,<4" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-datadir" }, - { name = "pytest-mpi", git = "https://github.com/aragilar/pytest-mpi.git?rev=nonzero" }, { name = "tabulate", specifier = ">=0.9" }, ] workspace-test = [ { name = "monoprop-bench-tools", editable = "../monoprop-bench-tools" }, { name = "psutil", specifier = ">=7.2" }, { name = "pytest", specifier = ">=8.2.2,<9.2" }, - { name = "pytest-cases", specifier = ">=3.8.6,<4" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-datadir" }, - { name = "pytest-mpi", git = "https://github.com/aragilar/pytest-mpi.git?rev=nonzero" }, { name = "tabulate", specifier = ">=0.9" }, ] diff --git a/pyproject.toml b/pyproject.toml index 6b13bf49c..2c6a59a76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,8 +76,6 @@ test = [ "psutil>=7.2", "pytest-cov>=7.1.0", "pytest-datadir", - # depend on branch which only outputs from rank zero - "pytest-mpi @ git+https://github.com/aragilar/pytest-mpi.git@nonzero", "pytest>=8.2.2,<9.2", "tabulate>=0.9", ] @@ -193,10 +191,10 @@ python_files = ["test_*.py", "bench_*.py"] pythonpath = ["."] markers = [ "slow: mark test as slow (>=1 minute runtime)", - "mpi: mark test as requiring --with-mpi (pytest-mpi)", + "mpi(min_size=1): mark test as requiring --with-mpi and a minimum MPI rank count", "qiskit: mark test as requiring qiskit (optional dependency)", ] -required_plugins = ["pytest-datadir", "pytest-mpi"] +required_plugins = ["pytest-datadir"] [tool.coverage.run] diff --git a/tests/conftest.py b/tests/conftest.py index 2f6870564..4bab8c411 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ """Pytest configuration for monoprop tests. -MPI selection is handled by ``pytest-mpi`` (``--with-mpi``, ``@pytest.mark.mpi``). +MPI selection is handled here (``--with-mpi``, ``@pytest.mark.mpi``). ``comm`` is parametrized over COMM_SELF and COMM_WORLD: use it for energies and gradients, which allreduce internally. ``serial_comm`` is always COMM_SELF: use it for rank-local state such as @@ -40,17 +40,46 @@ MPI = None +def pytest_addoption(parser: pytest.Parser) -> None: + """Register the opt-in for tests launched under MPI.""" + parser.getgroup("mpi").addoption( + "--with-mpi", + action="store_true", + default=False, + help="Run MPI tests; launch pytest with mpiexec.", + ) + + +@pytest.hookimpl(trylast=True) def pytest_configure(config: pytest.Config) -> None: - """Force pytest-mpi's ``--with-mpi`` under the VS Code adapter, which bypasses + """Enable ``--with-mpi`` under the VS Code adapter, which bypasses wrapper-level argument injection and would otherwise skip every ``mpi``-marked test. """ invocation_args = tuple(str(arg) for arg in config.invocation_params.args) is_vscode_run = config.pluginmanager.hasplugin("vscode_pytest") or any( "vscode_pytest" in arg for arg in invocation_args ) - if is_vscode_run and hasattr(config.option, "with_mpi"): + if is_vscode_run: config.option.with_mpi = True + if config.option.with_mpi and MPI is not None and MPI.COMM_WORLD.rank != 0: + reporter = config.pluginmanager.getplugin("terminalreporter") + if reporter is not None: + config.pluginmanager.unregister(reporter) + + +@pytest.hookimpl(tryfirst=True) +def pytest_runtest_setup(item: pytest.Item) -> None: + """Enforce MPI opt-in and rank requirements before setting up fixtures.""" + for marker in item.iter_markers("mpi"): + if not item.config.getoption("--with-mpi"): + pytest.skip("need --with-mpi option to run") + if MPI is None: + pytest.fail("MPI tests require mpi4py") + min_size = marker.kwargs.get("min_size", 1) + if MPI.COMM_WORLD.size < min_size: + pytest.skip(f"Test requires {min_size} MPI processes") + _COMM_PARAMS = ( [pytest.param(None, id="comm_none")] diff --git a/tests/test_mpi_configuration.py b/tests/test_mpi_configuration.py new file mode 100644 index 000000000..2cf14dca4 --- /dev/null +++ b/tests/test_mpi_configuration.py @@ -0,0 +1,117 @@ +# Copyright 2026 Algorithmiq +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Regression tests for the suite's MPI selection and reporting hooks.""" + +from unittest.mock import Mock + +import pytest + +from tests import conftest + + +@pytest.fixture +def mpi_config(monkeypatch): + monkeypatch.setattr(conftest, "MPI", None) + config = Mock() + config.option.with_mpi = False + config.invocation_params.args = () + config.pluginmanager.hasplugin.return_value = False + config.getoption.side_effect = lambda _: config.option.with_mpi + return config + + +@pytest.fixture +def mpi_item(mpi_config): + item = Mock(config=mpi_config) + item.iter_markers.return_value = [pytest.mark.mpi.mark] + return item + + +def test_mpi_tests_require_opt_in(mpi_item): + with pytest.raises(pytest.skip.Exception, match="need --with-mpi"): + conftest.pytest_runtest_setup(mpi_item) + + +@pytest.mark.parametrize("with_mpi", [False, True]) +def test_unmarked_tests_do_not_require_mpi(mpi_item, with_mpi): + mpi_item.config.option.with_mpi = with_mpi + mpi_item.iter_markers.return_value = [] + conftest.pytest_runtest_setup(mpi_item) + + +def test_mpi_opt_in_requires_mpi4py(mpi_item): + mpi_item.config.option.with_mpi = True + with pytest.raises(pytest.fail.Exception, match="MPI tests require mpi4py"): + conftest.pytest_runtest_setup(mpi_item) + + +@pytest.mark.parametrize("size", [1, 2, 4]) +def test_mpi_minimum_rank_count(mpi_item, monkeypatch, size): + monkeypatch.setattr(conftest, "MPI", Mock(COMM_WORLD=Mock(size=size))) + mpi_item.config.option.with_mpi = True + mpi_item.iter_markers.return_value = [pytest.mark.mpi(min_size=2).mark] + if size < 2: + with pytest.raises(pytest.skip.Exception, match="requires 2 MPI processes"): + conftest.pytest_runtest_setup(mpi_item) + else: + conftest.pytest_runtest_setup(mpi_item) + + +def test_mpi_marker_defaults_to_one_rank(mpi_item, monkeypatch): + monkeypatch.setattr(conftest, "MPI", Mock(COMM_WORLD=Mock(size=1))) + mpi_item.config.option.with_mpi = True + conftest.pytest_runtest_setup(mpi_item) + + +@pytest.mark.parametrize("adapter_plugin", [False, True]) +def test_vscode_enables_mpi(mpi_config, adapter_plugin): + mpi_config.pluginmanager.hasplugin.return_value = adapter_plugin + mpi_config.invocation_params.args = ( + () if adapter_plugin else ("-p", "vscode_pytest") + ) + conftest.pytest_configure(mpi_config) + assert mpi_config.option.with_mpi + + +def test_serial_configuration_does_not_require_mpi(mpi_config): + conftest.pytest_configure(mpi_config) + assert not mpi_config.option.with_mpi + mpi_config.pluginmanager.unregister.assert_not_called() + + +@pytest.mark.parametrize( + ("with_mpi", "rank", "muted"), + [(False, 1, False), (True, 0, False), (True, 1, True)], +) +def test_only_rank_zero_reports_under_mpi( + mpi_config, monkeypatch, with_mpi, rank, muted +): + monkeypatch.setattr(conftest, "MPI", Mock(COMM_WORLD=Mock(rank=rank))) + mpi_config.option.with_mpi = with_mpi + conftest.pytest_configure(mpi_config) + if muted: + mpi_config.pluginmanager.unregister.assert_called_once_with( + mpi_config.pluginmanager.getplugin.return_value + ) + else: + mpi_config.pluginmanager.unregister.assert_not_called() + + +def test_mpi_configuration_without_terminal_reporter(mpi_config, monkeypatch): + monkeypatch.setattr(conftest, "MPI", Mock(COMM_WORLD=Mock(rank=1))) + mpi_config.option.with_mpi = True + mpi_config.pluginmanager.getplugin.return_value = None + conftest.pytest_configure(mpi_config) + mpi_config.pluginmanager.unregister.assert_not_called() diff --git a/uv.lock b/uv.lock index cbb78569a..d70712214 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,6 @@ bench = [ { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "pytest-datadir" }, - { name = "pytest-mpi" }, { name = "tabulate" }, ] dev = [ @@ -1081,7 +1080,6 @@ test = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-datadir" }, - { name = "pytest-mpi" }, { name = "tabulate" }, ] workspace-test = [ @@ -1090,7 +1088,6 @@ workspace-test = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-datadir" }, - { name = "pytest-mpi" }, { name = "tabulate" }, ] @@ -1112,7 +1109,6 @@ bench = [ { name = "pytest-benchmark", specifier = ">=5" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-datadir" }, - { name = "pytest-mpi", git = "https://github.com/aragilar/pytest-mpi.git?rev=nonzero" }, { name = "tabulate", specifier = ">=0.9" }, ] dev = [ @@ -1141,7 +1137,6 @@ test = [ { name = "pytest", specifier = ">=8.2.2,<9.2" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-datadir" }, - { name = "pytest-mpi", git = "https://github.com/aragilar/pytest-mpi.git?rev=nonzero" }, { name = "tabulate", specifier = ">=0.9" }, ] workspace-test = [ @@ -1150,7 +1145,6 @@ workspace-test = [ { name = "pytest", specifier = ">=8.2.2,<9.2" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-datadir" }, - { name = "pytest-mpi", git = "https://github.com/aragilar/pytest-mpi.git?rev=nonzero" }, { name = "tabulate", specifier = ">=0.9" }, ] @@ -1870,14 +1864,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/b9/c3df11997d29e69b3f8edae1e903bf44eaf4774ccf4c5b6ddcebde88931c/pytest_markdown_docs-0.9.2-py3-none-any.whl", hash = "sha256:9c05a5bee48214cb36583d4a5131d3a23b327a8d82c92ae860106053fd7d1f9e", size = 13536, upload-time = "2026-03-23T12:35:03.862Z" }, ] -[[package]] -name = "pytest-mpi" -version = "0.6+9.g4b9aaba" -source = { git = "https://github.com/aragilar/pytest-mpi.git?rev=nonzero#4b9aabaff9d14ba7d5e7677f7034949c5790064a" } -dependencies = [ - { name = "pytest" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0"