diff --git a/pyomo/common/errors.py b/pyomo/common/errors.py index 58139fbd5eb..67039ced0ff 100644 --- a/pyomo/common/errors.py +++ b/pyomo/common/errors.py @@ -117,8 +117,6 @@ class ApplicationError(Exception): An exception used when an external application generates an error. """ - pass - class PyomoException(Exception): """ @@ -127,7 +125,84 @@ class PyomoException(Exception): (e.g., in other applications that use Pyomo). """ - pass + +class ExtendedPyomoException(PyomoException): + """ + Exception class that mixes the base PyomoException and format_exception + to allow developers to create custom (and prettily formatted) exceptions + while still inheriting from a common exception class + + Parameters + ---------- + message : str, optional + Main message for the exception. If not provided, subclasses can supply + a default message via the `message` attribute. + + prolog : str, optional + A message to output before the main message (like a header). + + extra_message : str, optional + Additional details or context, appended to the epilog. + + Examples + -------- + Basic usage with default message: + + >>> class NoFeasibleSolutionError(ExtendedPyomoException): + ... message = "No feasible solution found." + + >>> raise NoFeasibleSolutionError() + Traceback (most recent call last): + ... + NoFeasibleSolutionError: No feasible solution found. + + Providing extra information: + + >>> raise NoFeasibleSolutionError(extra_message="Solver status: infeasible.") + Traceback (most recent call last): + ... + NoFeasibleSolutionError: No feasible solution found. + Solver status: infeasible. + + Overriding the default message: + + >>> raise NoFeasibleSolutionError("Custom error message.") + Traceback (most recent call last): + ... + NoFeasibleSolutionError: Custom error message. + + Adding a prolog: + + >>> raise NoFeasibleSolutionError(prolog="Optimization error:") + Traceback (most recent call last): + ... + NoFeasibleSolutionError: Optimization error: + No feasible solution found. + + Combining prolog and extra_message: + + >>> raise NoFeasibleSolutionError(prolog="Optimization error:", extra_message="Solver log at /tmp/log.txt") + Traceback (most recent call last): + ... + NoFeasibleSolutionError: Optimization error: + No feasible solution found. + Solver log at /tmp/log.txt + """ + + message = None + + def __init__(self, message=None, *, prolog=None, extra_message=None, width=120): + main_message = message or self.message or "An error occurred." + + formatted_message = format_exception( + msg=main_message, + prolog=prolog, + epilog=extra_message, + exception=self.__class__, + width=width, + ) + + super().__init__(formatted_message) class DeferredImportError(ImportError): @@ -137,8 +212,6 @@ class DeferredImportError(ImportError): """ - pass - class DeveloperError(PyomoException, NotImplementedError): """ @@ -163,8 +236,6 @@ class InfeasibleConstraintException(PyomoException): the course of range reduction). """ - pass - class IterationLimitError(PyomoException, RuntimeError): """A subclass of :py:class:`RuntimeError`, raised by an iterative method @@ -182,16 +253,12 @@ class IntervalException(PyomoException, ValueError): Exception class used for errors in interval arithmetic. """ - pass - class InvalidValueError(PyomoException, ValueError): """ Exception class used for value errors in compiled model representations """ - pass - class MouseTrap(PyomoException, NotImplementedError): """ @@ -218,8 +285,6 @@ def __str__(self): class NondifferentiableError(PyomoException, ValueError): """A Pyomo-specific ValueError raised for non-differentiable expressions""" - pass - class TempfileContextError(PyomoException, IndexError): """A Pyomo-specific IndexError raised when attempting to use the @@ -227,8 +292,6 @@ class TempfileContextError(PyomoException, IndexError): """ - pass - class TemplateExpressionError(ValueError): """Special ValueError raised by getitem for template arguments diff --git a/pyomo/common/tests/test_errors.py b/pyomo/common/tests/test_errors.py index 07ac2d85c05..9b4fc4686c7 100644 --- a/pyomo/common/tests/test_errors.py +++ b/pyomo/common/tests/test_errors.py @@ -10,13 +10,17 @@ # ___________________________________________________________________________ import pyomo.common.unittest as unittest -from pyomo.common.errors import format_exception +from pyomo.common.errors import format_exception, ExtendedPyomoException class LocalException(Exception): pass +class CustomLocalException(ExtendedPyomoException): + message = 'Default message.' + + class TestFormatException(unittest.TestCase): def test_basic_message(self): self.assertEqual(format_exception("Hello world"), "Hello world") @@ -137,3 +141,34 @@ def test_basic_message_formatted_epilog(self): " inevitably wrap onto another line.\n" "Hello world:\n This is an epilog:", ) + + +class TestExtendedPyomoException(unittest.TestCase): + def test_default_message(self): + exception = CustomLocalException() + output = str(exception).replace("\n", " ").strip() + self.assertIn("Default message.", output) + + def test_custom_message_override(self): + exception = CustomLocalException("Non-default message.") + self.assertNotIn("Default message.", str(exception)) + self.assertIn("Non-default message.", str(exception)) + + def test_extra_message_epilog(self): + exception = CustomLocalException(extra_message="Epilog message.") + self.assertIn("Epilog message.", str(exception)) + + def test_prolog_message(self): + exception = CustomLocalException(prolog="Prolog message.") + self.assertIn("Prolog message.", str(exception)) + + def test_multiple_features(self): + exception = CustomLocalException( + "Non-default message.", + extra_message="Epilog message.", + prolog="Prolog message.", + ) + self.assertNotIn("Default message.", str(exception)) + self.assertIn("Non-default message.", str(exception)) + self.assertIn("Epilog message.", str(exception)) + self.assertIn("Prolog message.", str(exception)) diff --git a/pyomo/contrib/solver/common/util.py b/pyomo/contrib/solver/common/util.py index 084b8114c66..f65b449223b 100644 --- a/pyomo/contrib/solver/common/util.py +++ b/pyomo/contrib/solver/common/util.py @@ -9,60 +9,54 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.common.errors import PyomoException +from pyomo.common.errors import ExtendedPyomoException from pyomo.core.expr.visitor import ExpressionValueVisitor, nonpyomo_leaf_types import pyomo.core.expr as EXPR from pyomo.core.base.objective import Objective -class NoFeasibleSolutionError(PyomoException): - def __init__(self): - super().__init__( - 'A feasible solution was not found, so no solution can be loaded. ' - 'Please set opt.config.load_solutions=False and check ' - 'results.solution_status and ' - 'results.incumbent_objective before loading a solution.' - ) +class NoFeasibleSolutionError(ExtendedPyomoException): + message = ( + 'A feasible solution was not found, so no solution can be loaded. ' + 'Please set opt.config.load_solutions=False and check ' + 'results.solution_status and ' + 'results.incumbent_objective before loading a solution.' + ) -class NoOptimalSolutionError(PyomoException): - def __init__(self): - super().__init__( - 'Solver did not find the optimal solution. Set ' - 'opt.config.raise_exception_on_nonoptimal_result = False to bypass this error.' - ) +class NoOptimalSolutionError(ExtendedPyomoException): + message = ( + 'Solver did not find the optimal solution. Set ' + 'opt.config.raise_exception_on_nonoptimal_result = False to bypass this error.' + ) -class NoSolutionError(PyomoException): - def __init__(self): - super().__init__( - 'Solution loader does not currently have a valid solution. Please ' - 'check results.termination_condition and/or results.solution_status.' - ) +class NoSolutionError(ExtendedPyomoException): + message = ( + 'Solution loader does not currently have a valid solution. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) -class NoDualsError(PyomoException): - def __init__(self): - super().__init__( - 'Solver does not currently have valid duals. Please ' - 'check results.termination_condition and/or results.solution_status.' - ) +class NoDualsError(ExtendedPyomoException): + message = ( + 'Solver does not currently have valid duals. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) -class NoReducedCostsError(PyomoException): - def __init__(self): - super().__init__( - 'Solver does not currently have valid reduced costs. Please ' - 'check results.termination_condition and/or results.solution_status.' - ) +class NoReducedCostsError(ExtendedPyomoException): + message = ( + 'Solver does not currently have valid reduced costs. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) -class IncompatibleModelError(PyomoException): - def __init__(self): - super().__init__( - 'Model is not compatible with the chosen solver. Please check ' - 'the model and solver.' - ) +class IncompatibleModelError(ExtendedPyomoException): + message = ( + 'Model is not compatible with the chosen solver. Please check ' + 'the model and solver.' + ) def get_objective(block):