Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/content/docs/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 2 additions & 8 deletions packages/bench-third-party/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 2 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down Expand Up @@ -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]
Expand Down
35 changes: 32 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")]
Expand Down
117 changes: 117 additions & 0 deletions tests/test_mpi_configuration.py
Original file line number Diff line number Diff line change
@@ -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()
14 changes: 0 additions & 14 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading