diff --git a/petsctools/__init__.py b/petsctools/__init__.py index 58293cc..878b76b 100644 --- a/petsctools/__init__.py +++ b/petsctools/__init__.py @@ -14,7 +14,11 @@ # is not available then attempting to access these attributes will raise an # informative error. if PETSC4PY_INSTALLED: - from .citation import add_citation, cite, print_citations_at_exit # noqa: F401 + from .citation import ( # noqa: F401 + add_citation, + cite, + print_citations_at_exit, + ) from .config import get_blas_library # noqa: F401 from .init import ( # noqa: F401 InvalidEnvironmentException, diff --git a/petsctools/options.py b/petsctools/options.py index 5e5ef58..dc78f4a 100644 --- a/petsctools/options.py +++ b/petsctools/options.py @@ -1,10 +1,11 @@ from __future__ import annotations +import weakref import contextlib import functools import itertools import warnings -from typing import Any +from typing import Any, Iterable import petsc4py @@ -90,12 +91,42 @@ def munge(keys): if option in new: warnings.warn( f"Ignoring duplicate option: {option} (existing value " - f"{new[option]}, new value {value})", + f"{new[option]}, new value {value})", PetscToolsWarning ) new[option] = value return new +def _warn_unused_options(all_options: Iterable, used_options: Iterable, + options_prefix: str = ""): + """ + Raise warnings for PETSc options which were not used. + + This is meant only as a weakref.finalize callback for the OptionsManager. + + Parameters + ---------- + all_options : + The full set of options passed to the OptionsManager. + used_options : + The options which were used during the OptionsManager's lifetime. + options_prefix : + The options_prefix of the OptionsManager. + + Raises + ------ + PetscToolsWarning : + For every entry in all_options which is not in used_options. + """ + unused_options = set(all_options) - set(used_options) + + for option in sorted(unused_options): + warnings.warn( + f"Unused PETSc option: {options_prefix+option}", + PetscToolsWarning + ) + + class OptionsManager: """Class that helps with managing setting PETSc options. @@ -238,8 +269,17 @@ def __init__(self, parameters: dict, options_prefix: str | None): # since that does not DTRT for flag options. for k, v in self.options_object.getAll().items(): if k.startswith(self.options_prefix): - self.parameters[k[len(self.options_prefix) :]] = v + self.parameters[k[len(self.options_prefix):]] = v self._setfromoptions = False + # Keep track of options used between invocations of inserted_options(). + self._used_options = set() + + # Decide whether to warn for unused options + with self.inserted_options(): + if self.options_object.getInt("options_left", 0) > 0: + weakref.finalize(self, _warn_unused_options, + self.to_delete, self._used_options, + options_prefix=self.options_prefix) def set_default_parameter(self, key: str, val: Any) -> None: """Set a default parameter value. @@ -292,6 +332,8 @@ def inserted_options(self): yield finally: for k in self.to_delete: + if self.options_object.used(self.options_prefix + k): + self._used_options.add(k) del self.options_object[self.options_prefix + k] @functools.cached_property diff --git a/tests/test_options.py b/tests/test_options.py new file mode 100644 index 0000000..c6bd759 --- /dev/null +++ b/tests/test_options.py @@ -0,0 +1,76 @@ +import warnings +import pytest +import petsctools + + +@pytest.fixture(autouse=True, scope="module") +def temporarily_remove_options(): + """Remove all options when the module is entered and reinsert them at exit. + This ensures that options in e.g. petscrc files will not pollute the tests. + """ + if petsctools.PETSC4PY_INSTALLED: + PETSc = petsctools.init() + options = PETSc.Options() + previous_options = { + k: v for k, v in options.getAll().items() + } + options.clear() + yield + if petsctools.PETSC4PY_INSTALLED: + for k, v in previous_options.items(): + options[k] = v + + +@pytest.fixture(autouse=True) +def clear_options(): + """Clear any options from the database at the end of each test. + """ + yield + # PETSc already initialised by module scope fixture + from petsc4py import PETSc + PETSc.Options().clear() + + +@pytest.mark.skipnopetsc4py +@pytest.mark.parametrize("options_left", (-1, 0, 1), + ids=("no_options_left", + "options_left=0", + "options_left=1")) +def test_unused_options(options_left): + """Check that unused solver options result in a warning in the log.""" + # PETSc already initialised by module scope fixture + from petsc4py import PETSc + + if options_left >= 0: + PETSc.Options()["options_left"] = options_left + + parameters = { + "used": 1, + "not_used": 2, + } + options = petsctools.OptionsManager(parameters, options_prefix="optobj") + + with options.inserted_options(): + _ = PETSc.Options().getInt(options.options_prefix + "used") + + # No warnings should be raised in this case. + if options_left <= 0: + with warnings.catch_warnings(): + warnings.simplefilter("error") + del options + return + + # Destroying the object will trigger the unused options warning + with pytest.warns() as records: + del options + + # Exactly one option is both unused and not ignored + assert len(records) == 1 + message = str(records[0].message) + + # Does the warning include the options prefix? + assert "optobj" in message + + # Do we only raise a warning for the unused option? + assert "optobj_not_used" in message + assert "optobj_used" not in message