diff --git a/pyomo/contrib/solver/common/availability.py b/pyomo/contrib/solver/common/availability.py new file mode 100644 index 00000000000..6a558783e22 --- /dev/null +++ b/pyomo/contrib/solver/common/availability.py @@ -0,0 +1,57 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.enums import IntEnum + + +class SolverAvailability(IntEnum): + """ + Class to capture different statuses in which a solver can exist in + order to record its availability for use. + """ + + Available = 1 + NotFound = 0 + BadVersion = -1 + NeedsCompiledExtension = -2 + + def __bool__(self): + return self._value_ > 0 + + def __format__(self, format_spec): + return format(self.name, format_spec) + + def __str__(self): + return self.name + + +class LicenseAvailability(IntEnum): + """ + Runtime status for licensing. Independent from + overall solver availability. A return value > 0 is "usable in some form". + """ + + FullLicense = 3 + LimitedLicense = 2 + NotApplicable = 1 + NotAvailable = 0 + BadLicense = -1 + Timeout = -2 + Unknown = -3 + + def __bool__(self): + return self._value_ > 0 + + def __format__(self, format_spec): + return format(self.name, format_spec) + + def __str__(self): + return self.name diff --git a/pyomo/contrib/solver/common/base.py b/pyomo/contrib/solver/common/base.py index f60900f9207..8bd7697398c 100644 --- a/pyomo/contrib/solver/common/base.py +++ b/pyomo/contrib/solver/common/base.py @@ -18,7 +18,7 @@ from pyomo.core.base.block import BlockData from pyomo.core.base.objective import Objective, ObjectiveData from pyomo.common.config import ConfigValue, ConfigDict -from pyomo.common.enums import IntEnum, SolverAPIVersion +from pyomo.common.enums import SolverAPIVersion from pyomo.common.errors import ApplicationError from pyomo.common.deprecation import deprecation_warning from pyomo.common.modeling import NOTSET @@ -29,6 +29,10 @@ from pyomo.core.base.label import NumericLabeler from pyomo.core.staleflag import StaleFlagManager from pyomo.scripting.solve_config import default_config_block +from pyomo.contrib.solver.common.availability import ( + SolverAvailability, + LicenseAvailability, +) from pyomo.contrib.solver.common.config import SolverConfig, PersistentSolverConfig from pyomo.contrib.solver.common.util import get_objective from pyomo.contrib.solver.common.results import ( @@ -39,39 +43,17 @@ ) -class Availability(IntEnum): - """ - Class to capture different statuses in which a solver can exist in - order to record its availability for use. - """ - - FullLicense = 2 - LimitedLicense = 1 - NotFound = 0 - BadVersion = -1 - BadLicense = -2 - NeedsCompiledExtension = -3 - - def __bool__(self): - return self._value_ > 0 - - def __format__(self, format_spec): - return format(self.name, format_spec) - - def __str__(self): - return self.name - - class SolverBase: """The base class for "new-style" Pyomo solver interfaces. This base class defines the methods all derived solvers are expected to implement: - - :py:meth:`available` + - :py:meth:`solver_available` + - :py:meth:`license_available` - :py:meth:`is_persistent` - - :py:meth:`solve` - :py:meth:`version` + - :py:meth:`solve` **Class Configuration** @@ -138,31 +120,92 @@ def solve(self, model: BlockData, **kwargs) -> Results: f"Derived class {self.__class__.__name__} failed to implement required method 'solve'." ) - def available(self) -> Availability: - """Test if the solver is available on this system. + def available(self, recheck: bool = False, timeout: Optional[float] = 0) -> bool: + """Test if a solver is both available and licensed on this system. + + This function, which does not need to be implemented by any derived + class, returns a bool that represents if a solver is both + available to run (``solver_available``) and if it is properly + licensed to run (``license_available``). + + Parameters + ---------- + recheck: bool + A flag to trigger whether the overall availability should be + rechecked. Default behavior is to use the cached availability. + timeout: float + How long to wait for a license before declaring a timeout. + Default behavior is to not wait (i.e., a license either + needs to be available immediately or will return False) + + """ + return ( + self.solver_available(recheck=recheck).__bool__() + and self.license_available(recheck=recheck, timeout=timeout).__bool__() + ) + + def solver_available(self, recheck: bool = False) -> SolverAvailability: + """Test if the solver is available/findable on this system. + + Nominally, this will return ``True`` if the solver interface is + valid and findable (e.g., executable is on the path, solver is + importable), and will return ``False`` otherwise. - Nominally, this will return `True` if the solver interface is - valid and can be used to solve problems and `False` if it cannot. + Parameters + ---------- + recheck: bool + A flag to trigger whether the availability should be + rechecked. Default behavior is to use the cached availability. + + Returns + ------- + solver_available: SolverAvailability + An enum that indicates "how available" the solver is. + Note that the enum can be cast to bool, which will + be True if the solver is accessible and False + otherwise. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'solver_available'." + ) + + def license_available( + self, recheck: bool = False, timeout: Optional[float] = 0 + ) -> LicenseAvailability: + """Test if licensed solver has an available and usable license. + + The default behavior of this for solvers without licenses should be + to return ``True``. Note that for licensed solvers there are a number of "levels" of available: depending on the license, the solver may be available with limitations on problem size or runtime (e.g., 'demo' - vs. 'community' vs. 'full'). In these cases, the solver may - return a subclass of enum.IntEnum, with members that resolve to - True if the solver is available (possibly with limitations). - The Enum may also have multiple members that all resolve to - False indicating the reason why the interface is not available - (not found, bad license, unsupported version, etc). + vs. 'community' vs. 'full'). + Some solvers may also want to consider implementing + ``acquire_license`` and ``release_license`` if the license + needs to be checked out (e.g., gurobi), whereas others + may simply need to check for the existence of a + license file (e.g., BARON). + + Parameters + ---------- + recheck: bool + A flag to trigger whether the license availability should be + rechecked. Default behavior is to use the cached availability. + timeout: float + How long to wait for a license before declaring a timeout. + Default behavior is to not wait (i.e., a license either + needs to be available immediately or will return False) Returns ------- - available: Availability - An enum that indicates "how available" the solver is. + license_available: LicenseAvailability + An enum that indicates the license availability of a solver. Note that the enum can be cast to bool, which will - be True if the solver is runable at all and False + be True if the license is valid at all and False otherwise. """ raise NotImplementedError( - f"Derived class {self.__class__.__name__} failed to implement required method 'available'." + f"Derived class {self.__class__.__name__} failed to implement required method 'license_available'." ) def version(self) -> Tuple: diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index 45ea9dcc873..7e7d9297501 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -14,6 +14,9 @@ import math import operator import os +import time +import logging +from typing import Optional, Tuple from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.config import ConfigValue @@ -26,7 +29,11 @@ from pyomo.core.staleflag import StaleFlagManager from pyomo.repn.plugins.standard_form import LinearStandardFormCompiler -from pyomo.contrib.solver.common.base import SolverBase, Availability +from pyomo.contrib.solver.common.availability import ( + SolverAvailability, + LicenseAvailability, +) +from pyomo.contrib.solver.common.base import SolverBase from pyomo.contrib.solver.common.config import BranchAndBoundConfig from pyomo.contrib.solver.common.util import ( NoFeasibleSolutionError, @@ -44,6 +51,7 @@ from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +logger = logging.getLogger(__name__) gurobipy, gurobipy_available = attempt_import('gurobipy') @@ -174,83 +182,175 @@ class GurobiSolverMixin: duplicate code. """ + _gurobipy_available = gurobipy_available _num_gurobipy_env_clients = 0 _gurobipy_env = None - _available = None - _gurobipy_available = gurobipy_available - def available(self): - if self._available is None: - # this triggers the deferred import, and for the persistent - # interface, may update the _available flag - # - # Note that we set the _available flag on the *most derived - # class* and not on the instance, or on the base class. That - # allows different derived interfaces to have different - # availability (e.g., persistent has a minimum version - # requirement that the direct interface doesn't) - if not self._gurobipy_available: - if self._available is None: - self.__class__._available = Availability.NotFound - else: - self.__class__._available = self._check_license() - return self._available + _available_cache = None + _version_cache = None + _license_cache = None + + def solver_available(self, recheck: bool = False) -> SolverAvailability: + if not recheck and self._available_cache is not None: + return self._available_cache + # this triggers the deferred import, and for the persistent + # interface, may update the _available_cache flag + # + # Note that we set the _available_cache flag on the *most derived + # class* and not on the instance, or on the base class. That + # allows different derived interfaces to have different + # availability (e.g., persistent has a minimum version + # requirement that the direct interface doesn't) + if not self._gurobipy_available: + self.__class__._available_cache = SolverAvailability.NotFound + else: + self.__class__._available_cache = SolverAvailability.Available + return self._available_cache + + def version(self) -> Optional[Tuple[int, int, int]]: + if self._version_cache is None: + self.__class__._version_cache = ( + gurobipy.GRB.VERSION_MAJOR, + gurobipy.GRB.VERSION_MINOR, + gurobipy.GRB.VERSION_TECHNICAL, + ) + return self._version_cache + + def license_available( + self, recheck: bool = False, timeout: Optional[float] = 0 + ) -> LicenseAvailability: + """ + Attempts to acquire a license (by opening an Env and building a small model). + Responses: + - FullLicense : can optimize a small model with >2000 vars + - LimitedLicense: can optimize only up to demo/community limits + - NotAvailable : gurobi license not present/denied + - Timeout : waited but could not check out + - BadLicense : clearly invalid/corrupt license + - Unknown : unexpected error states; solver itself is unavailable + """ + if not self._gurobipy_available: + return LicenseAvailability.Unknown + if not recheck and self._license_cache is not None: + return self._license_cache + + with capture_output(capture_fd=True): + # Try to bring up an environment (this is where a license is often checked) + try: + env = self.acquire_license(timeout=timeout) + if env is None: + self._license_cache = LicenseAvailability.Timeout + return self._license_cache + except gurobipy.GurobiError as acquire_error: + # Distinguish timeout vs unavailable vs bad license + status = getattr(acquire_error, "errno", None) + msg = str(acquire_error).lower() + if "queue" in msg or "timeout" in msg: + self._license_cache = LicenseAvailability.Timeout + elif ( + "no gurobi license" in msg + or "not licensed" in msg + or status in (10009,) + ): + self._license_cache = LicenseAvailability.NotAvailable + else: + self._license_cache = LicenseAvailability.BadLicense + return self._license_cache + + # Build model to test license level + try: + # We try a 'big' model (more than 2000 vars). + # This should give us all the information we need + # about the license status. + large_model = gurobipy.Model(env=env) + large_model.addVars(range(2001)) + large_model.optimize() + self._license_cache = LicenseAvailability.FullLicense + except gurobipy.GurobiError as large_error: + msg = str(large_error).lower() + status = getattr(large_error, "errno", None) + if "too large" in msg or status in (10010,): + self._license_cache = LicenseAvailability.LimitedLicense + elif "queue" in msg or "timeout" in msg: + # We may still hit a timeout, so let's add this check + # just in case + self._license_cache = LicenseAvailability.Timeout + else: + # We have no idea what's going on otherwise + self._license_cache = LicenseAvailability.Unknown + finally: + large_model.dispose() + + return self._license_cache + + @classmethod + def acquire_license(cls, timeout: Optional[float] = 0): + # Quick check - already have license + if cls._gurobipy_env is not None: + return cls._gurobipy_env + if not timeout: + try: + cls._gurobipy_env = gurobipy.Env() + except gurobipy.GurobiError: + # Re-raise so license_available can inspect further + # or so users can explicitly view the error + raise + return cls._gurobipy_env + else: + current_time = time.time() + sleep_for = 0.1 + elapsed = time.time() - current_time + remaining = timeout - elapsed + while remaining > 0: + time.sleep(min(sleep_for, remaining)) + try: + cls._gurobipy_env = gurobipy.Env() + except Exception as e: + # Log and keep going + logger.info( + "Exception occurred during license timeout: %s", + e, + exc_info=True, + ) + if cls._gurobipy_env is not None: + return cls._gurobipy_env + sleep_for *= 2 + elapsed = time.time() - current_time + remaining = timeout - elapsed + logger.warning( + "Timed out after %.2f seconds trying to acquire a Gurobi license.", + timeout, + ) - @staticmethod - def release_license(): - if GurobiSolverMixin._gurobipy_env is None: + @classmethod + def release_license(cls): + """Close the shared gurobipy.Env when not referenced.""" + if cls._gurobipy_env is None: return - if GurobiSolverMixin._num_gurobipy_env_clients: + if cls._num_gurobipy_env_clients: logger.warning( "Call to GurobiSolverMixin.release_license() with %s remaining " - "environment clients." % (GurobiSolverMixin._num_gurobipy_env_clients,) + "environment clients.", + cls._num_gurobipy_env_clients, ) - GurobiSolverMixin._gurobipy_env.close() - GurobiSolverMixin._gurobipy_env = None - - @staticmethod - def env(): - if GurobiSolverMixin._gurobipy_env is None: - with capture_output(capture_fd=True): - GurobiSolverMixin._gurobipy_env = gurobipy.Env() - return GurobiSolverMixin._gurobipy_env - - @staticmethod - def _register_env_client(): - GurobiSolverMixin._num_gurobipy_env_clients += 1 - - @staticmethod - def _release_env_client(): - GurobiSolverMixin._num_gurobipy_env_clients -= 1 - if GurobiSolverMixin._num_gurobipy_env_clients <= 0: - # Note that _num_gurobipy_env_clients should never be <0, - # but if it is, release_license will issue a warning (that - # we want to know about) - GurobiSolverMixin.release_license() - - def _check_license(self): try: - model = gurobipy.Model(env=self.env()) - except gurobipy.GurobiError: - return Availability.BadLicense - - model.setParam('OutputFlag', 0) - try: - model.addVars(range(2001)) - model.optimize() - return Availability.FullLicense - except gurobipy.GurobiError: - return Availability.LimitedLicense - finally: - model.dispose() - - def version(self): - version = ( - gurobipy.GRB.VERSION_MAJOR, - gurobipy.GRB.VERSION_MINOR, - gurobipy.GRB.VERSION_TECHNICAL, - ) - return version + cls._gurobipy_env.close() + except Exception: + pass + cls._gurobipy_env = None + + def env(self): + return type(self).acquire_license() + + @classmethod + def _register_env_client(cls): + cls._num_gurobipy_env_clients += 1 + + @classmethod + def _release_env_client(cls): + cls._num_gurobipy_env_clients -= 1 + if cls._num_gurobipy_env_clients <= 0: + cls.release_license() class GurobiDirect(GurobiSolverMixin, SolverBase): diff --git a/pyomo/contrib/solver/solvers/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi_persistent.py index ea3693c1c70..50b19e90a58 100644 --- a/pyomo/contrib/solver/solvers/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi_persistent.py @@ -30,7 +30,7 @@ from pyomo.core.expr.numvalue import value, is_constant, is_fixed, native_numeric_types from pyomo.repn import generate_standard_repn from pyomo.core.expr.numeric_expr import NPV_MaxExpression, NPV_MinExpression -from pyomo.contrib.solver.common.base import PersistentSolverBase, Availability +from pyomo.contrib.solver.common.base import PersistentSolverBase, SolverAvailability from pyomo.contrib.solver.common.results import ( Results, TerminationCondition, @@ -64,10 +64,10 @@ def _import_gurobipy(): try: import gurobipy except ImportError: - GurobiPersistent._available = Availability.NotFound + GurobiPersistent._available_cache = SolverAvailability.NotFound raise if gurobipy.GRB.VERSION_MAJOR < 7: - GurobiPersistent._available = Availability.BadVersion + GurobiPersistent._available_cache = SolverAvailability.BadVersion raise ImportError('The Persistent Gurobi interface requires gurobipy>=7.0.0') return gurobipy @@ -272,13 +272,24 @@ def __init__(self, **kwds): self._vars_added_since_update = ComponentSet() self._last_results_object: Optional[Results] = None + def close(self): + try: + self._reinit() + except Exception: + pass + type(self).release_license() + def release_license(self): - self._reinit() - self.__class__.release_license() + # This is a bit of a hack; I defined a classmethod version of + # release_license which causes a name clash. + self.close() def __del__(self): - if not python_is_shutting_down(): - self._release_env_client() + try: + if not python_is_shutting_down(): + type(self)._release_env_client() + except Exception: + pass @property def symbol_map(self): diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 2fdac4942c8..c7b0b040756 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -11,7 +11,7 @@ import logging import io -from typing import List, Optional +from typing import List, Optional, Tuple from pyomo.common.collections import ComponentMap from pyomo.common.dependencies import attempt_import @@ -29,7 +29,11 @@ from pyomo.common.dependencies import numpy as np from pyomo.core.staleflag import StaleFlagManager -from pyomo.contrib.solver.common.base import PersistentSolverBase, Availability +from pyomo.contrib.solver.common.availability import ( + SolverAvailability, + LicenseAvailability, +) +from pyomo.contrib.solver.common.base import PersistentSolverBase from pyomo.contrib.solver.common.results import ( Results, TerminationCondition, @@ -242,8 +246,6 @@ class Highs(PersistentSolverMixin, PersistentSolverUtils, PersistentSolverBase): CONFIG = PersistentBranchAndBoundConfig() - _available = None - def __init__(self, **kwds): treat_fixed_vars_as_params = kwds.pop('treat_fixed_vars_as_params', True) PersistentSolverBase.__init__(self, **kwds) @@ -258,27 +260,42 @@ def __init__(self, **kwds): self._mutable_bounds = {} self._last_results_object: Optional[Results] = None self._sol = None + self._available_cache = None + self._version_cache = None - def available(self): - if highspy_available: - return Availability.FullLicense - return Availability.NotFound - - def version(self): - try: - version = ( - highspy.HIGHS_VERSION_MAJOR, - highspy.HIGHS_VERSION_MINOR, - highspy.HIGHS_VERSION_PATCH, - ) - except AttributeError: - # Older versions of Highs do not have the above attributes - # and the solver version can only be obtained by making - # an instance of the solver class. - tmp = highspy.Highs() - version = (tmp.versionMajor(), tmp.versionMinor(), tmp.versionPatch()) - - return version + def solver_available(self, recheck: bool = False) -> SolverAvailability: + if recheck or self._available_cache is None: + if not highspy_available: + self._available_cache = SolverAvailability.NotFound + else: + self._available_cache = SolverAvailability.Available + return self._available_cache + + def license_available( + self, recheck: bool = False, timeout: Optional[float] = 0 + ) -> LicenseAvailability: + # HiGHS doesn't require a license + return LicenseAvailability.NotApplicable + + def version(self) -> Optional[Tuple[int, int, int]]: + if self._version_cache is None: + try: + self._version_cache = ( + highspy.HIGHS_VERSION_MAJOR, + highspy.HIGHS_VERSION_MINOR, + highspy.HIGHS_VERSION_PATCH, + ) + except AttributeError: + # Older versions of Highs do not have the above attributes + # and the solver version can only be obtained by making + # an instance of the solver class. + tmp = highspy.Highs() + self._version_cache = ( + tmp.versionMajor(), + tmp.versionMinor(), + tmp.versionPatch(), + ) + return self._version_cache def _solve(self): config = self._active_config diff --git a/pyomo/contrib/solver/solvers/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py index a7ed5435aa7..63638a665fb 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -37,7 +37,11 @@ from pyomo.core.base.var import VarData from pyomo.core.staleflag import StaleFlagManager from pyomo.repn.plugins.nl_writer import NLWriter, NLWriterInfo -from pyomo.contrib.solver.common.base import SolverBase, Availability +from pyomo.contrib.solver.common.availability import ( + SolverAvailability, + LicenseAvailability, +) +from pyomo.contrib.solver.common.base import SolverBase from pyomo.contrib.solver.common.config import SolverConfig from pyomo.contrib.solver.common.factory import LegacySolverWrapper from pyomo.contrib.solver.common.results import ( @@ -217,7 +221,7 @@ def get_reduced_costs( 'wantsol': 'The solver interface requires the sol file to be created', 'option_file_name': ( 'Pyomo generates the ipopt options file as part of the `solve` ' - 'method. Add all options to ipopt.config.solver_options instead.' + 'method. Add all options to ipopt.config.solver_options instead.' ), } @@ -241,23 +245,23 @@ def __init__(self, **kwds: Any) -> None: #: see :ref:`pyomo.contrib.solver.solvers.ipopt.Ipopt::CONFIG`. self.config = self.config - def available(self, config: Optional[IpoptConfig] = None) -> Availability: - if config is None: - config = self.config - pth = config.executable.path() - if self._available_cache is None or self._available_cache[0] != pth: + def solver_available(self, recheck: bool = False) -> SolverAvailability: + pth = self.config.executable.path() + if recheck or self._available_cache is None or self._available_cache[0] != pth: if pth is None: - self._available_cache = (None, Availability.NotFound) + self._available_cache = (None, SolverAvailability.NotFound) else: - self._available_cache = (pth, Availability.FullLicense) + self._available_cache = (pth, SolverAvailability.Available) return self._available_cache[1] - def version( - self, config: Optional[IpoptConfig] = None - ) -> Optional[Tuple[int, int, int]]: - if config is None: - config = self.config - pth = config.executable.path() + def license_available( + self, recheck: bool = False, timeout: Optional[float] = 0 + ) -> LicenseAvailability: + # Ipopt doesn't require a license + return LicenseAvailability.NotApplicable + + def version(self) -> Optional[Tuple[int, int, int]]: + pth = self.config.executable.path() if self._version_cache is None or self._version_cache[0] != pth: if pth is None: self._version_cache = (None, None) @@ -344,7 +348,7 @@ def solve(self, model, **kwds) -> Results: # Update configuration options, based on keywords passed to solve config: IpoptConfig = self.config(value=kwds, preserve_implicit=True) # Check if solver is available - avail = self.available(config) + avail = self.available() if not avail: raise ApplicationError( f'Solver {self.__class__} is not available ({avail}).' @@ -524,7 +528,7 @@ def solve(self, model, **kwds) -> Results: raise NoOptimalSolutionError() results.solver_name = self.name - results.solver_version = self.version(config) + results.solver_version = self.version() if config.load_solutions: if results.solution_status == SolutionStatus.noSolution: diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_direct.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_direct.py new file mode 100644 index 00000000000..9082d1b86b7 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_direct.py @@ -0,0 +1,258 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.tee import capture_output +from pyomo.common import unittest + +from pyomo.contrib.solver.solvers.gurobi_direct import ( + gurobipy_available, + GurobiSolverMixin, + GurobiDirect, +) +from pyomo.contrib.solver.common.availability import ( + SolverAvailability, + LicenseAvailability, +) + + +class TestGurobiMixin(unittest.TestCase): + + MODULE_PATH = "pyomo.contrib.solver.solvers.gurobi_direct" + + def setUp(self): + # Reset shared state before each test + GurobiSolverMixin._gurobipy_env = None + GurobiSolverMixin._license_cache = None + GurobiSolverMixin._available_cache = None + GurobiSolverMixin._version_cache = None + GurobiSolverMixin._num_gurobipy_env_clients = 0 + + class GurobiError(Exception): + def __init__(self, msg="", errno=None): + super().__init__(msg) + self.errno = errno + + class Env: + pass + + class Model: + def __init__(self, env=None, license_status="ok"): + self.license_status = license_status + self.disposed = False + + def addVars(self, rng): + return None + + def optimize(self): + if self.license_status == "ok": + return + if self.license_status == "too_large": + raise TestGurobiMixin.GurobiError("Model too large", errno=10010) + if self.license_status == "timeout": + raise TestGurobiMixin.GurobiError("timeout waiting for license") + if self.license_status == "no_license": + raise TestGurobiMixin.GurobiError("no gurobi license", errno=10009) + if self.license_status == "bad": + raise TestGurobiMixin.GurobiError("other licensing problem") + + def dispose(self): + self.disposed = True + + @staticmethod + def mocked_gurobipy(license_status="ok", env_side_effect=None): + """ + Build a fake gurobipy module. + - license_status controls Model.optimize() behavior + - env_side_effect (callable or Exception) controls Env() behavior + e.g. env_side_effect=TestGurobiMixin.GurobiError("no gurobi license", errno=10009) + """ + + class GRB: + # Arbitrarily picking a version + VERSION_MAJOR = 12 + VERSION_MINOR = 0 + VERSION_TECHNICAL = 1 + + class Param: + OutputFlag = 0 + + mocker = unittest.mock.MagicMock() + if env_side_effect is None: + mocker.Env = unittest.mock.MagicMock(return_value=TestGurobiMixin.Env()) + else: + if isinstance(env_side_effect, Exception): + mocker.Env = unittest.mock.MagicMock(side_effect=env_side_effect) + else: + mocker.Env = unittest.mock.MagicMock(side_effect=env_side_effect) + mocker.Model = unittest.mock.MagicMock( + side_effect=lambda **kw: TestGurobiMixin.Model( + license_status=license_status + ) + ) + mocker.GRB = GRB + mocker.GurobiError = TestGurobiMixin.GurobiError + return mocker + + def test_solver_available(self): + mixin = GurobiSolverMixin() + with unittest.mock.patch.object(GurobiSolverMixin, "_gurobipy_available", True): + self.assertEqual(mixin.solver_available(), SolverAvailability.Available) + + def test_solver_unavailable(self): + mixin = GurobiSolverMixin() + with unittest.mock.patch.object( + GurobiSolverMixin, "_gurobipy_available", False + ): + self.assertEqual(mixin.solver_available(), SolverAvailability.NotFound) + + def test_solver_available_recheck(self): + mixin = GurobiSolverMixin() + with unittest.mock.patch.object( + GurobiSolverMixin, "_gurobipy_available", False + ): + self.assertEqual(mixin.solver_available(), SolverAvailability.NotFound) + with unittest.mock.patch.object(GurobiSolverMixin, "_gurobipy_available", True): + # Should first return the cached value + self.assertEqual(mixin.solver_available(), SolverAvailability.NotFound) + # Should now return the recheck value + self.assertEqual( + mixin.solver_available(recheck=True), SolverAvailability.Available + ) + + def test_license_available_solver_not_available(self): + mixin = GurobiSolverMixin() + with unittest.mock.patch.object( + GurobiSolverMixin, "_gurobipy_available", False + ): + with capture_output(capture_fd=True): + output = mixin.license_available() + self.assertEqual(output, LicenseAvailability.Unknown) + + def test_full_license(self): + mixin = GurobiSolverMixin() + mock_gp = self.mocked_gurobipy("ok") + with ( + unittest.mock.patch.object(GurobiSolverMixin, "_gurobipy_available", True), + unittest.mock.patch(f"{self.MODULE_PATH}.gurobipy", mock_gp), + ): + with capture_output(capture_fd=True): + output = mixin.license_available() + self.assertEqual(output, LicenseAvailability.FullLicense) + + def test_limited_license(self): + mixin = GurobiSolverMixin() + mock_gp = self.mocked_gurobipy("too_large") + with ( + unittest.mock.patch.object(GurobiSolverMixin, "_gurobipy_available", True), + unittest.mock.patch(f"{self.MODULE_PATH}.gurobipy", mock_gp), + ): + with capture_output(capture_fd=True): + output = mixin.license_available() + self.assertEqual(output, LicenseAvailability.LimitedLicense) + + def test_no_license(self): + mixin = GurobiSolverMixin() + env_error = self.GurobiError("no gurobi license", errno=10009) + mock_gp = self.mocked_gurobipy(license_status="ok", env_side_effect=env_error) + with ( + unittest.mock.patch.object(GurobiSolverMixin, "_gurobipy_available", True), + unittest.mock.patch(f"{self.MODULE_PATH}.gurobipy", mock_gp), + ): + with capture_output(capture_fd=True): + output = mixin.license_available() + self.assertEqual(output, LicenseAvailability.NotAvailable) + + def test_license_timeout(self): + mixin = GurobiSolverMixin() + env_error = self.GurobiError("timeout waiting for license") + mock_gp = self.mocked_gurobipy(license_status="ok", env_side_effect=env_error) + with ( + unittest.mock.patch.object(GurobiSolverMixin, "_gurobipy_available", True), + unittest.mock.patch(f"{self.MODULE_PATH}.gurobipy", mock_gp), + ): + with capture_output(capture_fd=True): + output = mixin.license_available(timeout=1) + self.assertEqual(output, LicenseAvailability.Timeout) + + def test_license_available_recheck(self): + mixin = GurobiSolverMixin() + mock_gp_full = self.mocked_gurobipy("ok") + with ( + unittest.mock.patch.object(GurobiSolverMixin, "_gurobipy_available", True), + unittest.mock.patch(f"{self.MODULE_PATH}.gurobipy", mock_gp_full), + ): + with capture_output(capture_fd=True): + output = mixin.license_available() + self.assertEqual(output, LicenseAvailability.FullLicense) + + # Clear the cached Env so acquire_license() re-runs + GurobiSolverMixin._gurobipy_env = None + + env_error = self.GurobiError("no gurobi license", errno=10009) + mock_gp_none = self.mocked_gurobipy( + license_status="ok", env_side_effect=env_error + ) + with ( + unittest.mock.patch.object(GurobiSolverMixin, "_gurobipy_available", True), + unittest.mock.patch(f"{self.MODULE_PATH}.gurobipy", mock_gp_none), + ): + with capture_output(capture_fd=True): + output_cached = mixin.license_available() + # Should return the cached value first because we didn't ask + # for a recheck + self.assertEqual(output_cached, LicenseAvailability.FullLicense) + + with capture_output(capture_fd=True): + output_recheck = mixin.license_available(recheck=True) + # Should officially recheck + self.assertEqual(output_recheck, LicenseAvailability.NotAvailable) + + def test_version(self): + mixin = GurobiSolverMixin() + mock_gp = self.mocked_gurobipy() + with unittest.mock.patch(f"{self.MODULE_PATH}.gurobipy", mock_gp): + self.assertEqual(mixin.version(), (12, 0, 1)) + # Verify that the cache works + mock_gp.GRB.VERSION_MINOR = 99 + self.assertEqual(mixin.version(), (12, 0, 1)) + + def test_acquire_license(self): + mixin = GurobiSolverMixin() + mock_gp = self.mocked_gurobipy() + with unittest.mock.patch(f"{self.MODULE_PATH}.gurobipy", mock_gp): + env = mixin.acquire_license() + self.assertIs(env, mixin._gurobipy_env) + self.assertIs(mixin.env(), env) + + def test_release_license(self): + mock_env = unittest.mock.MagicMock() + GurobiSolverMixin._gurobipy_env = mock_env + GurobiSolverMixin._num_gurobipy_env_clients = 0 + + GurobiSolverMixin.release_license() + + mock_env.close.assert_called_once() + self.assertIsNone(GurobiSolverMixin._gurobipy_env) + + +@unittest.skipIf(not gurobipy_available, "The 'gurobipy' module is not available.") +class TestGurobiDirectInterface(unittest.TestCase): + def test_solver_available_cache(self): + opt = GurobiDirect() + opt.solver_available() + self.assertTrue(opt._available_cache) + self.assertIsNotNone(opt._available_cache) + + def test_version_cache(self): + opt = GurobiDirect() + opt.version() + self.assertIsNotNone(opt._version_cache[0]) + self.assertIsNotNone(opt._version_cache[1]) diff --git a/pyomo/contrib/solver/tests/solvers/test_highs.py b/pyomo/contrib/solver/tests/solvers/test_highs.py index f59a0bfa42d..178cfc1c6a6 100644 --- a/pyomo/contrib/solver/tests/solvers/test_highs.py +++ b/pyomo/contrib/solver/tests/solvers/test_highs.py @@ -13,12 +13,31 @@ import pyomo.environ as pyo from pyomo.contrib.solver.solvers.highs import Highs +from pyomo.contrib.solver.common.availability import LicenseAvailability opt = Highs() if not opt.available(): raise unittest.SkipTest +class TestHighsInterface(unittest.TestCase): + def test_solver_available_cache(self): + opt = Highs() + opt.solver_available() + self.assertTrue(opt._available_cache) + self.assertIsNotNone(opt._available_cache) + + def test_license_available(self): + opt = Highs() + self.assertEqual(opt.license_available(), LicenseAvailability.NotApplicable) + + def test_version_cache(self): + opt = Highs() + opt.version() + self.assertIsNotNone(opt._version_cache[0]) + self.assertIsNotNone(opt._version_cache[1]) + + class TestBugs(unittest.TestCase): def test_mutable_params_with_remove_cons(self): m = pyo.ConcreteModel() diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index 06553da1bf0..9814d3d3631 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -19,6 +19,7 @@ from pyomo.common.errors import DeveloperError from pyomo.common.tee import capture_output import pyomo.contrib.solver.solvers.ipopt as ipopt +from pyomo.contrib.solver.common.availability import LicenseAvailability from pyomo.contrib.solver.common.util import NoSolutionError from pyomo.contrib.solver.common.results import TerminationCondition, SolutionStatus from pyomo.contrib.solver.common.factory import SolverFactory @@ -99,22 +100,6 @@ def test_command_line_options(self): options.append(option_name) self.assertEqual(sorted(ipopt.ipopt_command_line_options), sorted(options)) - def test_class_member_list(self): - opt = ipopt.Ipopt() - expected_list = [ - 'CONFIG', - 'config', - 'api_version', - 'available', - 'has_linear_solver', - 'is_persistent', - 'solve', - 'version', - 'name', - ] - method_list = [method for method in dir(opt) if method.startswith('_') is False] - self.assertEqual(sorted(expected_list), sorted(method_list)) - def test_default_instantiation(self): opt = ipopt.Ipopt() self.assertFalse(opt.is_persistent()) @@ -131,29 +116,21 @@ def test_context_manager(self): self.assertEqual(opt.CONFIG, opt.config) self.assertTrue(opt.available()) - def test_available_cache(self): + def test_solver_available_cache(self): opt = ipopt.Ipopt() - opt.available() + opt.solver_available() self.assertTrue(opt._available_cache[1]) self.assertIsNotNone(opt._available_cache[0]) - # Now we will try with a custom config that has a fake path - config = ipopt.IpoptConfig() - config.executable = Executable('/a/bogus/path') - opt.available(config=config) - self.assertFalse(opt._available_cache[1]) - self.assertIsNone(opt._available_cache[0]) + + def test_license_available(self): + opt = ipopt.Ipopt() + self.assertEqual(opt.license_available(), LicenseAvailability.NotApplicable) def test_version_cache(self): opt = ipopt.Ipopt() opt.version() self.assertIsNotNone(opt._version_cache[0]) self.assertIsNotNone(opt._version_cache[1]) - # Now we will try with a custom config that has a fake path - config = ipopt.IpoptConfig() - config.executable = Executable('/a/bogus/path') - opt.version(config=config) - self.assertIsNone(opt._version_cache[0]) - self.assertIsNone(opt._version_cache[1]) def test_parse_output(self): # Old ipopt style (<=3.13) @@ -338,8 +315,9 @@ def test_verify_ipopt_options(self): ) with self.assertRaisesRegex( ValueError, - r'Pyomo generates the ipopt options file as part of the `solve` ' - r'method. Add all options to ipopt.config.solver_options instead', + r"unallowed ipopt option 'option_file_name': Pyomo generates the " + r"ipopt options file as part of the `solve` method. Add all " + r"options to ipopt.config.solver_options instead.", ): opt._verify_ipopt_options(opt.config) diff --git a/pyomo/contrib/solver/tests/unit/test_availability.py b/pyomo/contrib/solver/tests/unit/test_availability.py new file mode 100644 index 00000000000..24500c74a1b --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/test_availability.py @@ -0,0 +1,47 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common import unittest +from pyomo.contrib.solver.common.availability import ( + SolverAvailability, + LicenseAvailability, +) + + +class TestSolverAvailability(unittest.TestCase): + def test_statuses(self): + self.assertTrue(bool(SolverAvailability.Available)) + self.assertFalse(bool(SolverAvailability.NotFound)) + self.assertFalse(bool(SolverAvailability.BadVersion)) + self.assertFalse(bool(SolverAvailability.NeedsCompiledExtension)) + + def test_str_and_format(self): + self.assertEqual(str(SolverAvailability.Available), "Available") + self.assertEqual(f"{SolverAvailability.BadVersion}", "BadVersion") + formatted = "{:>15}".format(SolverAvailability.Available) + self.assertIn("Available", formatted) + + +class TestLicenseAvailability(unittest.TestCase): + def test_statuses(self): + self.assertTrue(bool(LicenseAvailability.FullLicense)) + self.assertTrue(bool(LicenseAvailability.LimitedLicense)) + self.assertTrue(bool(LicenseAvailability.NotApplicable)) + self.assertFalse(bool(LicenseAvailability.NotAvailable)) + self.assertFalse(bool(LicenseAvailability.BadLicense)) + self.assertFalse(bool(LicenseAvailability.Timeout)) + self.assertFalse(bool(LicenseAvailability.Unknown)) + + def test_str_and_format(self): + self.assertEqual(str(LicenseAvailability.FullLicense), "FullLicense") + self.assertEqual(f"{LicenseAvailability.Timeout}", "Timeout") + formatted = "{:<20}".format(LicenseAvailability.NotApplicable) + self.assertIn("NotApplicable", formatted) diff --git a/pyomo/contrib/solver/tests/unit/test_base.py b/pyomo/contrib/solver/tests/unit/test_base.py index 217b02b9999..d32a7150006 100644 --- a/pyomo/contrib/solver/tests/unit/test_base.py +++ b/pyomo/contrib/solver/tests/unit/test_base.py @@ -27,6 +27,8 @@ def test_class_method_list(self): 'CONFIG', 'api_version', 'available', + 'license_available', + 'solver_available', 'is_persistent', 'solve', 'version', @@ -78,6 +80,8 @@ def test_class_method_list(self): 'add_variables', 'api_version', 'available', + 'license_available', + 'solver_available', 'is_persistent', 'remove_block', 'remove_constraints',