From f76547b6ab463822a19440adb38c8a1f97103b62 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Tue, 16 Sep 2025 13:40:58 -0600 Subject: [PATCH 01/12] Separate available and license_available --- pyomo/contrib/solver/common/availability.py | 54 +++++ pyomo/contrib/solver/common/base.py | 82 ++++--- pyomo/contrib/solver/solvers/gurobi_direct.py | 218 +++++++++++++----- .../solver/solvers/gurobi_persistent.py | 20 +- pyomo/contrib/solver/solvers/highs.py | 62 +++-- pyomo/contrib/solver/solvers/ipopt.py | 33 +-- .../solver/tests/solvers/test_ipopt.py | 33 +-- pyomo/contrib/solver/tests/unit/test_base.py | 2 + 8 files changed, 339 insertions(+), 165 deletions(-) create mode 100644 pyomo/contrib/solver/common/availability.py diff --git a/pyomo/contrib/solver/common/availability.py b/pyomo/contrib/solver/common/availability.py new file mode 100644 index 00000000000..d2b1b11c299 --- /dev/null +++ b/pyomo/contrib/solver/common/availability.py @@ -0,0 +1,54 @@ +# ___________________________________________________________________________ +# +# 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 Availability(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 __str__(self): + return self.name diff --git a/pyomo/contrib/solver/common/base.py b/pyomo/contrib/solver/common/base.py index f60900f9207..8a4362ae344 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,7 @@ 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 Availability, 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,29 +40,6 @@ ) -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. @@ -69,6 +47,7 @@ class SolverBase: to implement: - :py:meth:`available` + - :py:meth:`license_available` - :py:meth:`is_persistent` - :py:meth:`solve` - :py:meth:`version` @@ -138,33 +117,72 @@ def solve(self, model: BlockData, **kwargs) -> Results: f"Derived class {self.__class__.__name__} failed to implement required method 'solve'." ) - def available(self) -> Availability: + def available(self, recheck: bool = False) -> Availability: """Test if the solver is available on this system. Nominally, this will return `True` if the solver interface is valid and can be used to solve problems and `False` if it cannot. - 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). + Parameters + ---------- + recheck: bool + A flag to trigger whether the availability should be + rechecked. Default behavior is to use the cached availability. + Returns ------- available: Availability 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 runable at all and False + be True if the solver is runnable at all and False otherwise. """ raise NotImplementedError( f"Derived class {self.__class__.__name__} failed to implement required method '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'). + 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., mosek). + + 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 + ------- + 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 license is valid at all and False + otherwise. + """ + raise NotImplementedError( + f"Derived class {self.__class__.__name__} failed to implement required method 'license_available'." + ) + def version(self) -> Tuple: """Return the solver version found on the system. diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index 45ea9dcc873..bed2c487ae3 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -14,6 +14,8 @@ import math import operator import os +import logging +from typing import Optional, Tuple from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.config import ConfigValue @@ -26,7 +28,8 @@ 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 Availability, 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 +47,7 @@ from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +logger = logging.getLogger(__name__) gurobipy, gurobipy_available = attempt_import('gurobipy') @@ -174,83 +178,172 @@ 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: + _available_cache = None + _version_cache = None + _license_cache = None + + def available(self, recheck: bool = False) -> Availability: + if recheck or self._available_cache is None: # this triggers the deferred import, and for the persistent - # interface, may update the _available flag + # interface, may update the _available_cache flag # - # Note that we set the _available flag on the *most derived + # 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: - if self._available is None: - self.__class__._available = Availability.NotFound + self.__class__._available_cache = Availability.NotFound else: - self.__class__._available = self._check_license() - return self._available + self.__class__._available_cache = Availability.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 + """ + if not gurobipy_available: + return LicenseAvailability.NotAvailable + + if not recheck and self._license_cache is not None: + return self._license_cache + + # Handle license wait via environment + old_wait = os.environ.get("GRB_LICENSE_WAIT", None) + if timeout and timeout > 0: + # Gurobi treats this as integer seconds if set + os.environ["GRB_LICENSE_WAIT"] = str(int(timeout)) + else: + # Ensure we don't inherit a user's shell setting for this probe + # (but preserve it to restore later) + os.environ["GRB_LICENSE_WAIT"] = os.environ.get("GRB_LICENSE_WAIT", "0") - @staticmethod - def release_license(): - if GurobiSolverMixin._gurobipy_env is None: + try: + # Try to bring up an environment (this is where a license is often checked) + try: + env = self._ensure_env() + except gurobipy.GurobiError as e: + # Distinguish timeout vs unavailable vs bad license + code = getattr(e, "errno", None) + msg = str(e).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 code in (10009,) + ): + self._license_cache = LicenseAvailability.NotAvailable + else: + self._license_cache = LicenseAvailability.BadLicense + return self._license_cache + + # Build model to test license level + with capture_output(capture_fd=True): + large_model = gurobipy.Model(env=env) + + try: + # First try to exceed typical demo/community limits + # (demo traditionally allows up to 2000 vars/cons) + large_model.addVars(range(2001)) + large_model.optimize() + self._license_cache = LicenseAvailability.FullLicense + except gurobipy.GurobiError: + # If we fail when exceeding limits, try within limits + with capture_output(capture_fd=True): + small_model = gurobipy.Model(env=env) + try: + small_model.addVars( + range(100) + ) # comfortably under demo limits + small_model.optimize() + self._license_cache = LicenseAvailability.LimitedLicense + except gurobipy.GurobiError as small_error: + # Could be a denied/expired/invalid license + small_msg = str(small_error).lower() + small_status = getattr(small_error, "errno", None) + if "queue" in small_msg or "timeout" in small_msg: + self._license_cache = LicenseAvailability.Timeout + elif ( + "no gurobi license" in small_msg + or "not licensed" in small_msg + or small_status in (10009,) + ): + self._license_cache = LicenseAvailability.NotAvailable + else: + self._license_cache = LicenseAvailability.BadLicense + finally: + small_model.dispose() + finally: + large_model.dispose() + + finally: + # Restore environment + if old_wait is None: + os.environ.pop("GRB_LICENSE_WAIT", None) + else: + os.environ["GRB_LICENSE_WAIT"] = old_wait + + return self._license_cache + + @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 + cls._gurobipy_env.close() + except Exception: + pass + cls._gurobipy_env = None + + @classmethod + def _ensure_env(cls): + if cls._gurobipy_env is None: + with capture_output(capture_fd=True): + cls._gurobipy_env = gurobipy.Env() + return cls._gurobipy_env - model.setParam('OutputFlag', 0) - try: - model.addVars(range(2001)) - model.optimize() - return Availability.FullLicense - except gurobipy.GurobiError: - return Availability.LimitedLicense - finally: - model.dispose() + def env(self): + return type(self)._ensure_env() - def version(self): - version = ( - gurobipy.GRB.VERSION_MAJOR, - gurobipy.GRB.VERSION_MINOR, - gurobipy.GRB.VERSION_TECHNICAL, - ) - return version + @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): @@ -279,6 +372,13 @@ def solve(self, model, **kwds) -> Results: f'Solver {c.__module__}.{c.__qualname__} is not available ' f'({self.available()}).' ) + if not self.license_available(): + c = self.__class__ + raise ApplicationError( + f'Solver {c.__module__}.{c.__qualname__} does ' + 'not have an available license ' + f'({self.license_available()}).' + ) if config.timer is None: config.timer = HierarchicalTimer() timer = config.timer diff --git a/pyomo/contrib/solver/solvers/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi_persistent.py index ea3693c1c70..8af1687bd22 100644 --- a/pyomo/contrib/solver/solvers/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi_persistent.py @@ -272,13 +272,25 @@ def __init__(self, **kwds): self._vars_added_since_update = ComponentSet() self._last_results_object: Optional[Results] = None + def close(self): + """Instance-level cleanup: drop model state, then release shared Env.""" + 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..da25b692574 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,8 @@ 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 Availability, LicenseAvailability +from pyomo.contrib.solver.common.base import PersistentSolverBase from pyomo.contrib.solver.common.results import ( Results, TerminationCondition, @@ -242,8 +243,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 +257,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 available(self, recheck: bool = False) -> Availability: + if recheck or self._available_cache is None: + if not highspy_available: + self._available_cache = Availability.NotFound + else: + self._available_cache = Availability.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..41e0e1164f1 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -37,7 +37,8 @@ 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 Availability, 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 +218,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 +242,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 available(self, recheck: bool = False) -> Availability: + 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) else: - self._available_cache = (pth, Availability.FullLicense) + self._available_cache = (pth, Availability.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 +345,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 +525,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_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index 06553da1bf0..b5fcca085fc 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -99,22 +99,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()) @@ -136,24 +120,12 @@ def test_available_cache(self): opt.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_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 +310,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_base.py b/pyomo/contrib/solver/tests/unit/test_base.py index 217b02b9999..3411b58ae90 100644 --- a/pyomo/contrib/solver/tests/unit/test_base.py +++ b/pyomo/contrib/solver/tests/unit/test_base.py @@ -27,6 +27,7 @@ def test_class_method_list(self): 'CONFIG', 'api_version', 'available', + 'license_available', 'is_persistent', 'solve', 'version', @@ -78,6 +79,7 @@ def test_class_method_list(self): 'add_variables', 'api_version', 'available', + 'license_available', 'is_persistent', 'remove_block', 'remove_constraints', From 6d1806179597f907389fe736c8c2141a929cf4e0 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Tue, 16 Sep 2025 13:50:23 -0600 Subject: [PATCH 02/12] Add some availability tests --- pyomo/contrib/solver/common/availability.py | 3 ++ .../solver/tests/unit/test_availability.py | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 pyomo/contrib/solver/tests/unit/test_availability.py diff --git a/pyomo/contrib/solver/common/availability.py b/pyomo/contrib/solver/common/availability.py index d2b1b11c299..e89a33f2776 100644 --- a/pyomo/contrib/solver/common/availability.py +++ b/pyomo/contrib/solver/common/availability.py @@ -50,5 +50,8 @@ class LicenseAvailability(IntEnum): 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/tests/unit/test_availability.py b/pyomo/contrib/solver/tests/unit/test_availability.py new file mode 100644 index 00000000000..f6423d2e0ef --- /dev/null +++ b/pyomo/contrib/solver/tests/unit/test_availability.py @@ -0,0 +1,44 @@ +# ___________________________________________________________________________ +# +# 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 Availability, LicenseAvailability + + +class TestAvailability(unittest.TestCase): + def test_statuses(self): + self.assertTrue(bool(Availability.Available)) + self.assertFalse(bool(Availability.NotFound)) + self.assertFalse(bool(Availability.BadVersion)) + self.assertFalse(bool(Availability.NeedsCompiledExtension)) + + def test_str_and_format(self): + self.assertEqual(str(Availability.Available), "Available") + self.assertEqual(f"{Availability.BadVersion}", "BadVersion") + formatted = "{:>15}".format(Availability.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) From 8139343ef7a73335d83f4ce47ee3da3d82cb6ff5 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Tue, 16 Sep 2025 14:26:20 -0600 Subject: [PATCH 03/12] Clean up some docs --- pyomo/contrib/solver/solvers/gurobi_direct.py | 7 ++----- pyomo/contrib/solver/solvers/gurobi_persistent.py | 1 - 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index bed2c487ae3..b6fb1a88418 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -236,7 +236,7 @@ def license_available( # Gurobi treats this as integer seconds if set os.environ["GRB_LICENSE_WAIT"] = str(int(timeout)) else: - # Ensure we don't inherit a user's shell setting for this probe + # Ensure we don't inherit a user's shell settings # (but preserve it to restore later) os.environ["GRB_LICENSE_WAIT"] = os.environ.get("GRB_LICENSE_WAIT", "0") @@ -271,13 +271,10 @@ def license_available( large_model.optimize() self._license_cache = LicenseAvailability.FullLicense except gurobipy.GurobiError: - # If we fail when exceeding limits, try within limits with capture_output(capture_fd=True): small_model = gurobipy.Model(env=env) try: - small_model.addVars( - range(100) - ) # comfortably under demo limits + small_model.addVars(range(100)) small_model.optimize() self._license_cache = LicenseAvailability.LimitedLicense except gurobipy.GurobiError as small_error: diff --git a/pyomo/contrib/solver/solvers/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi_persistent.py index 8af1687bd22..8732e4984e9 100644 --- a/pyomo/contrib/solver/solvers/gurobi_persistent.py +++ b/pyomo/contrib/solver/solvers/gurobi_persistent.py @@ -273,7 +273,6 @@ def __init__(self, **kwds): self._last_results_object: Optional[Results] = None def close(self): - """Instance-level cleanup: drop model state, then release shared Env.""" try: self._reinit() except Exception: From 9ada68afa83aef455a20f1485d69a42f2f7e33fb Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Tue, 16 Sep 2025 16:16:26 -0600 Subject: [PATCH 04/12] Simplify the license check --- pyomo/contrib/solver/solvers/gurobi_direct.py | 117 +++++++----------- 1 file changed, 48 insertions(+), 69 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index b6fb1a88418..aa6d62ad8c4 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -230,78 +230,57 @@ def license_available( if not recheck and self._license_cache is not None: return self._license_cache - # Handle license wait via environment - old_wait = os.environ.get("GRB_LICENSE_WAIT", None) - if timeout and timeout > 0: - # Gurobi treats this as integer seconds if set - os.environ["GRB_LICENSE_WAIT"] = str(int(timeout)) - else: - # Ensure we don't inherit a user's shell settings - # (but preserve it to restore later) - os.environ["GRB_LICENSE_WAIT"] = os.environ.get("GRB_LICENSE_WAIT", "0") - + # Try to bring up an environment (this is where a license is often checked) try: - # Try to bring up an environment (this is where a license is often checked) - try: - env = self._ensure_env() - except gurobipy.GurobiError as e: - # Distinguish timeout vs unavailable vs bad license - code = getattr(e, "errno", None) - msg = str(e).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 code in (10009,) - ): - self._license_cache = LicenseAvailability.NotAvailable - else: - self._license_cache = LicenseAvailability.BadLicense - return self._license_cache + env = self._ensure_env() + except gurobipy.GurobiError as e: + # Distinguish timeout vs unavailable vs bad license + code = getattr(e, "errno", None) + msg = str(e).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 code in (10009,) + ): + self._license_cache = LicenseAvailability.NotAvailable + else: + self._license_cache = LicenseAvailability.BadLicense + return self._license_cache - # Build model to test license level - with capture_output(capture_fd=True): + # Build model to test license level + with capture_output(capture_fd=True): + try: + # First try to exceed typical demo/community limits + # (demo traditionally allows up to 2000 vars/cons) large_model = gurobipy.Model(env=env) - - try: - # First try to exceed typical demo/community limits - # (demo traditionally allows up to 2000 vars/cons) - large_model.addVars(range(2001)) - large_model.optimize() - self._license_cache = LicenseAvailability.FullLicense - except gurobipy.GurobiError: - with capture_output(capture_fd=True): - small_model = gurobipy.Model(env=env) - try: - small_model.addVars(range(100)) - small_model.optimize() - self._license_cache = LicenseAvailability.LimitedLicense - except gurobipy.GurobiError as small_error: - # Could be a denied/expired/invalid license - small_msg = str(small_error).lower() - small_status = getattr(small_error, "errno", None) - if "queue" in small_msg or "timeout" in small_msg: - self._license_cache = LicenseAvailability.Timeout - elif ( - "no gurobi license" in small_msg - or "not licensed" in small_msg - or small_status in (10009,) - ): - self._license_cache = LicenseAvailability.NotAvailable - else: - self._license_cache = LicenseAvailability.BadLicense - finally: - small_model.dispose() - finally: - large_model.dispose() - - finally: - # Restore environment - if old_wait is None: - os.environ.pop("GRB_LICENSE_WAIT", None) - else: - os.environ["GRB_LICENSE_WAIT"] = old_wait + large_model.addVars(range(2001)) + large_model.optimize() + self._license_cache = LicenseAvailability.FullLicense + except gurobipy.GurobiError: + with capture_output(capture_fd=True): + small_model = gurobipy.Model(env=env) + try: + small_model.addVars(range(100)) + small_model.optimize() + self._license_cache = LicenseAvailability.LimitedLicense + except gurobipy.GurobiError as small_error: + # Could be a denied/expired/invalid license + small_msg = str(small_error).lower() + small_status = getattr(small_error, "errno", None) + if "queue" in small_msg or "timeout" in small_msg: + self._license_cache = LicenseAvailability.Timeout + elif ( + "no gurobi license" in small_msg + or "not licensed" in small_msg + or small_status in (10009,) + ): + self._license_cache = LicenseAvailability.NotAvailable + else: + self._license_cache = LicenseAvailability.BadLicense + finally: + small_model.dispose() + finally: + large_model.dispose() return self._license_cache From ebfc1304eedfefdc3a769fb59c0f2d3af5cc9d24 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Wed, 17 Sep 2025 13:13:48 -0600 Subject: [PATCH 05/12] Rework to solver_available, license_available, and available --- pyomo/contrib/solver/common/availability.py | 2 +- pyomo/contrib/solver/common/base.py | 47 ++++-- pyomo/contrib/solver/solvers/gurobi_direct.py | 155 ++++++++++-------- .../solver/solvers/gurobi_persistent.py | 6 +- pyomo/contrib/solver/solvers/highs.py | 11 +- pyomo/contrib/solver/solvers/ipopt.py | 11 +- .../solver/tests/unit/test_availability.py | 21 ++- pyomo/contrib/solver/tests/unit/test_base.py | 2 + 8 files changed, 156 insertions(+), 99 deletions(-) diff --git a/pyomo/contrib/solver/common/availability.py b/pyomo/contrib/solver/common/availability.py index e89a33f2776..6a558783e22 100644 --- a/pyomo/contrib/solver/common/availability.py +++ b/pyomo/contrib/solver/common/availability.py @@ -12,7 +12,7 @@ from pyomo.common.enums import IntEnum -class Availability(IntEnum): +class SolverAvailability(IntEnum): """ Class to capture different statuses in which a solver can exist in order to record its availability for use. diff --git a/pyomo/contrib/solver/common/base.py b/pyomo/contrib/solver/common/base.py index 8a4362ae344..446f85c8468 100644 --- a/pyomo/contrib/solver/common/base.py +++ b/pyomo/contrib/solver/common/base.py @@ -29,7 +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 Availability, LicenseAvailability +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 ( @@ -117,14 +120,36 @@ def solve(self, model: BlockData, **kwargs) -> Results: f"Derived class {self.__class__.__name__} failed to implement required method 'solve'." ) - def available(self, recheck: bool = False) -> 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 can be used to solve problems and `False` if it cannot. - 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). + valid and findable (e.g., executable is on the path, solver is + importable), and will return `False` otherwise. Parameters ---------- @@ -134,14 +159,14 @@ def available(self, recheck: bool = False) -> Availability: Returns ------- - available: Availability + 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 runnable at all and False + be True if the solver is accessible 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 'solver_available'." ) def license_available( @@ -159,7 +184,7 @@ def license_available( `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., mosek). + license file (e.g., BARON). Parameters ---------- diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index aa6d62ad8c4..5b96029c074 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -14,6 +14,7 @@ import math import operator import os +import time import logging from typing import Optional, Tuple @@ -28,7 +29,10 @@ from pyomo.core.staleflag import StaleFlagManager from pyomo.repn.plugins.standard_form import LinearStandardFormCompiler -from pyomo.contrib.solver.common.availability import Availability, LicenseAvailability +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 ( @@ -186,20 +190,21 @@ class GurobiSolverMixin: _version_cache = None _license_cache = None - def available(self, recheck: bool = False) -> Availability: - if recheck or self._available_cache is None: - # 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 = Availability.NotFound - else: - self.__class__._available_cache = Availability.Available + 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]]: @@ -226,64 +231,87 @@ def license_available( """ if not gurobipy_available: return LicenseAvailability.NotAvailable - if not recheck and self._license_cache is not None: return self._license_cache - # Try to bring up an environment (this is where a license is often checked) - try: - env = self._ensure_env() - except gurobipy.GurobiError as e: - # Distinguish timeout vs unavailable vs bad license - code = getattr(e, "errno", None) - msg = str(e).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 code in (10009,) - ): - self._license_cache = LicenseAvailability.NotAvailable - else: - self._license_cache = LicenseAvailability.BadLicense - return self._license_cache - - # Build model to test license level 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) + 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: - # First try to exceed typical demo/community limits - # (demo traditionally allows up to 2000 vars/cons) + # 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: - with capture_output(capture_fd=True): - small_model = gurobipy.Model(env=env) - try: - small_model.addVars(range(100)) - small_model.optimize() - self._license_cache = LicenseAvailability.LimitedLicense - except gurobipy.GurobiError as small_error: - # Could be a denied/expired/invalid license - small_msg = str(small_error).lower() - small_status = getattr(small_error, "errno", None) - if "queue" in small_msg or "timeout" in small_msg: - self._license_cache = LicenseAvailability.Timeout - elif ( - "no gurobi license" in small_msg - or "not licensed" in small_msg - or small_status in (10009,) - ): - self._license_cache = LicenseAvailability.NotAvailable - else: - self._license_cache = LicenseAvailability.BadLicense - finally: - small_model.dispose() + 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: + 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 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: + pass + if cls._gurobipy_env is not None: + 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: + pass + if cls._gurobipy_env is not None: + return cls._gurobipy_env + sleep_for *= 2 + elapsed = time.time() - current_time + remaining = timeout - elapsed + @classmethod def release_license(cls): """Close the shared gurobipy.Env when not referenced.""" @@ -301,15 +329,8 @@ def release_license(cls): pass cls._gurobipy_env = None - @classmethod - def _ensure_env(cls): - if cls._gurobipy_env is None: - with capture_output(capture_fd=True): - cls._gurobipy_env = gurobipy.Env() - return cls._gurobipy_env - def env(self): - return type(self)._ensure_env() + return type(self).acquire_license() @classmethod def _register_env_client(cls): diff --git a/pyomo/contrib/solver/solvers/gurobi_persistent.py b/pyomo/contrib/solver/solvers/gurobi_persistent.py index 8732e4984e9..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 diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index da25b692574..c7b0b040756 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -29,7 +29,10 @@ from pyomo.common.dependencies import numpy as np from pyomo.core.staleflag import StaleFlagManager -from pyomo.contrib.solver.common.availability import Availability, LicenseAvailability +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, @@ -260,12 +263,12 @@ def __init__(self, **kwds): self._available_cache = None self._version_cache = None - def available(self, recheck: bool = False) -> Availability: + def solver_available(self, recheck: bool = False) -> SolverAvailability: if recheck or self._available_cache is None: if not highspy_available: - self._available_cache = Availability.NotFound + self._available_cache = SolverAvailability.NotFound else: - self._available_cache = Availability.Available + self._available_cache = SolverAvailability.Available return self._available_cache def license_available( diff --git a/pyomo/contrib/solver/solvers/ipopt.py b/pyomo/contrib/solver/solvers/ipopt.py index 41e0e1164f1..63638a665fb 100644 --- a/pyomo/contrib/solver/solvers/ipopt.py +++ b/pyomo/contrib/solver/solvers/ipopt.py @@ -37,7 +37,10 @@ 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.availability import Availability, LicenseAvailability +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 @@ -242,13 +245,13 @@ def __init__(self, **kwds: Any) -> None: #: see :ref:`pyomo.contrib.solver.solvers.ipopt.Ipopt::CONFIG`. self.config = self.config - def available(self, recheck: bool = False) -> Availability: + 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.Available) + self._available_cache = (pth, SolverAvailability.Available) return self._available_cache[1] def license_available( diff --git a/pyomo/contrib/solver/tests/unit/test_availability.py b/pyomo/contrib/solver/tests/unit/test_availability.py index f6423d2e0ef..24500c74a1b 100644 --- a/pyomo/contrib/solver/tests/unit/test_availability.py +++ b/pyomo/contrib/solver/tests/unit/test_availability.py @@ -10,20 +10,23 @@ # ___________________________________________________________________________ from pyomo.common import unittest -from pyomo.contrib.solver.common.availability import Availability, LicenseAvailability +from pyomo.contrib.solver.common.availability import ( + SolverAvailability, + LicenseAvailability, +) -class TestAvailability(unittest.TestCase): +class TestSolverAvailability(unittest.TestCase): def test_statuses(self): - self.assertTrue(bool(Availability.Available)) - self.assertFalse(bool(Availability.NotFound)) - self.assertFalse(bool(Availability.BadVersion)) - self.assertFalse(bool(Availability.NeedsCompiledExtension)) + 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(Availability.Available), "Available") - self.assertEqual(f"{Availability.BadVersion}", "BadVersion") - formatted = "{:>15}".format(Availability.Available) + self.assertEqual(str(SolverAvailability.Available), "Available") + self.assertEqual(f"{SolverAvailability.BadVersion}", "BadVersion") + formatted = "{:>15}".format(SolverAvailability.Available) self.assertIn("Available", formatted) diff --git a/pyomo/contrib/solver/tests/unit/test_base.py b/pyomo/contrib/solver/tests/unit/test_base.py index 3411b58ae90..d32a7150006 100644 --- a/pyomo/contrib/solver/tests/unit/test_base.py +++ b/pyomo/contrib/solver/tests/unit/test_base.py @@ -28,6 +28,7 @@ def test_class_method_list(self): 'api_version', 'available', 'license_available', + 'solver_available', 'is_persistent', 'solve', 'version', @@ -80,6 +81,7 @@ def test_class_method_list(self): 'api_version', 'available', 'license_available', + 'solver_available', 'is_persistent', 'remove_block', 'remove_constraints', From 69d7afe33d613fa7d93285271425cb70945d516b Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Wed, 17 Sep 2025 13:16:04 -0600 Subject: [PATCH 06/12] Direct license check no longer necessary --- pyomo/contrib/solver/solvers/gurobi_direct.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index 5b96029c074..c1f928d7ff8 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -369,13 +369,6 @@ def solve(self, model, **kwds) -> Results: f'Solver {c.__module__}.{c.__qualname__} is not available ' f'({self.available()}).' ) - if not self.license_available(): - c = self.__class__ - raise ApplicationError( - f'Solver {c.__module__}.{c.__qualname__} does ' - 'not have an available license ' - f'({self.license_available()}).' - ) if config.timer is None: config.timer = HierarchicalTimer() timer = config.timer From e13e10f9da7aefcb8dfa1b3d967eeda755d61232 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Wed, 17 Sep 2025 14:28:09 -0600 Subject: [PATCH 07/12] Update tests to ensure correct behavior; actually add minimal gurobi_direct tests --- .../tests/solvers/test_gurobi_direct.py | 227 ++++++++++++++++++ .../solver/tests/solvers/test_highs.py | 19 ++ .../solver/tests/solvers/test_ipopt.py | 9 +- 3 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 pyomo/contrib/solver/tests/solvers/test_gurobi_direct.py 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..006fd7e1e11 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_direct.py @@ -0,0 +1,227 @@ +# ___________________________________________________________________________ +# +# 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"): + class GRB: + # Arbitrarily picking a version + VERSION_MAJOR = 12 + VERSION_MINOR = 0 + VERSION_TECHNICAL = 1 + + class Param: + OutputFlag = 0 + + mocker = unittest.mock.MagicMock() + mocker.Env = unittest.mock.MagicMock(return_value=TestGurobiMixin.Env()) + 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_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() + mock_gp = self.mocked_gurobipy("no_license") + 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() + mock_gp = self.mocked_gurobipy("timeout") + 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) + + mock_gp_none = self.mocked_gurobipy("no_license") + 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 = mixin.license_available() + # Should return the cached value first because we didn't ask + # for a recheck + self.assertEqual(output, LicenseAvailability.FullLicense) + with capture_output(capture_fd=True): + output = mixin.license_available(recheck=True) + # Should officially recheck + self.assertEqual(output, 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 b5fcca085fc..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 @@ -115,12 +116,16 @@ 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]) + 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() From 190bf09937e50e9351b565fbe693ab168dca9f00 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Wed, 17 Sep 2025 14:32:53 -0600 Subject: [PATCH 08/12] Missed a docstring update --- pyomo/contrib/solver/common/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/common/base.py b/pyomo/contrib/solver/common/base.py index 446f85c8468..589b9fa4584 100644 --- a/pyomo/contrib/solver/common/base.py +++ b/pyomo/contrib/solver/common/base.py @@ -49,11 +49,11 @@ class SolverBase: 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** From 799739d6557bb2ecd8568d95827b7988c6c0e97a Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Wed, 17 Sep 2025 14:53:34 -0600 Subject: [PATCH 09/12] Look, the tests caught a bug --- pyomo/contrib/solver/solvers/gurobi_direct.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index c1f928d7ff8..9477a0d5094 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -229,7 +229,7 @@ def license_available( - BadLicense : clearly invalid/corrupt license - Unknown : unexpected error states """ - if not gurobipy_available: + if not self._gurobipy_available: return LicenseAvailability.NotAvailable if not recheck and self._license_cache is not None: return self._license_cache From ccab51684c4962235b6731bf82e637180a73f80d Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Wed, 17 Sep 2025 15:13:39 -0600 Subject: [PATCH 10/12] Add log message if timeout achieved; set LicenseAvailability explicitly --- pyomo/contrib/solver/solvers/gurobi_direct.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index 9477a0d5094..afc67962fb5 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -238,6 +238,9 @@ def license_available( # 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) @@ -311,6 +314,10 @@ def acquire_license(cls, timeout: Optional[float] = 0): 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, + ) @classmethod def release_license(cls): From 6fc3fcb7b87ebe69d0f31635a0c5f98dc4350909 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Wed, 17 Sep 2025 15:31:17 -0600 Subject: [PATCH 11/12] Fix docstrings --- pyomo/contrib/solver/common/base.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/solver/common/base.py b/pyomo/contrib/solver/common/base.py index 589b9fa4584..8bd7697398c 100644 --- a/pyomo/contrib/solver/common/base.py +++ b/pyomo/contrib/solver/common/base.py @@ -125,8 +125,8 @@ def available(self, recheck: bool = False, timeout: Optional[float] = 0) -> bool 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`). + available to run (``solver_available``) and if it is properly + licensed to run (``license_available``). Parameters ---------- @@ -147,9 +147,9 @@ def available(self, recheck: bool = False, timeout: Optional[float] = 0) -> 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 + 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. + importable), and will return ``False`` otherwise. Parameters ---------- @@ -175,13 +175,13 @@ def license_available( """Test if licensed solver has an available and usable license. The default behavior of this for solvers without licenses should be - to return `True`. + 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'). Some solvers may also want to consider implementing - `acquire_license` and `release_license` if the license + ``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). From 0c57d1aa2ccddb03f231027d45d1f6dd4ef378c4 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Thu, 18 Sep 2025 11:01:44 -0600 Subject: [PATCH 12/12] Address michaelbynum's comments --- pyomo/contrib/solver/solvers/gurobi_direct.py | 33 +++++++------ .../tests/solvers/test_gurobi_direct.py | 49 +++++++++++++++---- 2 files changed, 58 insertions(+), 24 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index afc67962fb5..7e7d9297501 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -227,10 +227,10 @@ def license_available( - NotAvailable : gurobi license not present/denied - Timeout : waited but could not check out - BadLicense : clearly invalid/corrupt license - - Unknown : unexpected error states + - Unknown : unexpected error states; solver itself is unavailable """ if not self._gurobipy_available: - return LicenseAvailability.NotAvailable + return LicenseAvailability.Unknown if not recheck and self._license_cache is not None: return self._license_cache @@ -272,15 +272,12 @@ def license_available( 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 - 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 + # We have no idea what's going on otherwise + self._license_cache = LicenseAvailability.Unknown finally: large_model.dispose() @@ -294,10 +291,11 @@ def acquire_license(cls, timeout: Optional[float] = 0): if not timeout: try: cls._gurobipy_env = gurobipy.Env() - except: - pass - if cls._gurobipy_env is not None: - return cls._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 @@ -307,8 +305,13 @@ def acquire_license(cls, timeout: Optional[float] = 0): time.sleep(min(sleep_for, remaining)) try: cls._gurobipy_env = gurobipy.Env() - except: - pass + 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 diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_direct.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_direct.py index 006fd7e1e11..9082d1b86b7 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_direct.py @@ -67,7 +67,14 @@ def dispose(self): self.disposed = True @staticmethod - def mocked_gurobipy(license_status="ok"): + 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 @@ -78,7 +85,13 @@ class Param: OutputFlag = 0 mocker = unittest.mock.MagicMock() - mocker.Env = unittest.mock.MagicMock(return_value=TestGurobiMixin.Env()) + 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 @@ -114,6 +127,15 @@ def test_solver_available_recheck(self): 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") @@ -138,7 +160,8 @@ def test_limited_license(self): def test_no_license(self): mixin = GurobiSolverMixin() - mock_gp = self.mocked_gurobipy("no_license") + 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), @@ -149,7 +172,8 @@ def test_no_license(self): def test_license_timeout(self): mixin = GurobiSolverMixin() - mock_gp = self.mocked_gurobipy("timeout") + 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), @@ -169,20 +193,27 @@ def test_license_available_recheck(self): output = mixin.license_available() self.assertEqual(output, LicenseAvailability.FullLicense) - mock_gp_none = self.mocked_gurobipy("no_license") + # 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 = mixin.license_available() + output_cached = mixin.license_available() # Should return the cached value first because we didn't ask # for a recheck - self.assertEqual(output, LicenseAvailability.FullLicense) + self.assertEqual(output_cached, LicenseAvailability.FullLicense) + with capture_output(capture_fd=True): - output = mixin.license_available(recheck=True) + output_recheck = mixin.license_available(recheck=True) # Should officially recheck - self.assertEqual(output, LicenseAvailability.NotAvailable) + self.assertEqual(output_recheck, LicenseAvailability.NotAvailable) def test_version(self): mixin = GurobiSolverMixin()