Repository: Pyomo/pyomo

Issue:
Manage Gurobi environments explicitly in GurobiDirect
## Summary

It would be useful to add some capability to manage Gurobi environments (`gurobipy.Env` objects) in the `GurobiDirect` solver.

I'm happy to submit a PR for the changes described below, but could use some guidance as to the best option that fits with pyomo's style & API.

### Rationale

Some Gurobi customers using pyomo to build their models have run into issues with locking up shared resources (compute servers, floating licenses, etc). When using gurobipy directly the solution is to use context managers to explicitly free those resources once they are done with. This is tricky to achieve in pyomo since Gurobi environments are not used explicitly (the global 'default' environment is used).

Additionally, some parameters cannot be set without explicitly creating Gurobi environments e.g. ISV keys (see #2401), or [MemLimit](https://www.gurobi.com/documentation/9.5/refman/memlimit.html) added in a recent release.

### Description

Several possible solutions:

- Explicitly create a `gurobipy.Env` in `__init__` or `__enter__` in `GurobiDirect`
- Provide a `.close()` method in `GurobiDirect` to free all gurobi resources
- Allow users to create and manage an environment themselves using `gurobipy` and pass it as a keyword argument to `SolverFactory`

Some technical points to fix:

- `GurobiDirect.available()` checks for a license, but if it encounters an error (e.g. the license is temporarily in use) then it tracks this outcome globally and the solver is completely locked. `available()` might be better off just checking if `gurobipy` can be imported and leave the license check until a model needs to be created.
- `_solver_model` should be explicitly freed if overwritten

### Additional information

I've written some [test cases](https://github.com/simonbowly/pyomo/blob/gurobi-environments/pyomo/solvers/tests/checks/test_gurobi_environments.py) to try to make the above points clear.


Discussion:
Hello, @simonbowly. We appreciate your suggestion! We will discuss this request and add it to our backlog for future efforts.

_NOTE_: There is no guarantee that we will implement all suggestions. We do, however, accept external contributions. If you are interested in expediting this request, we encourage you to review our [Contribution Guide](https://pyomo.readthedocs.io/en/stable/contribution_guide.html). Feel free to ask us for more details on contributing to Pyomo as well.

Resolving patch:
diff --git a/pyomo/solvers/plugins/solvers/gurobi_direct.py b/pyomo/solvers/plugins/solvers/gurobi_direct.py
--- a/pyomo/solvers/plugins/solvers/gurobi_direct.py
+++ b/pyomo/solvers/plugins/solvers/gurobi_direct.py
@@ -74,15 +74,80 @@ def _parse_gurobi_version(gurobipy, avail):
 )
 
 
+def _set_options(model_or_env, options):
+    # Set a parameters from the dictionary 'options' on the given gurobipy
+    # model or environment.
+    for key, option in options.items():
+        # When options come from the pyomo command, all
+        # values are string types, so we try to cast
+        # them to a numeric value in the event that
+        # setting the parameter fails.
+        try:
+            model_or_env.setParam(key, option)
+        except TypeError:
+            # we place the exception handling for
+            # checking the cast of option to a float in
+            # another function so that we can simply
+            # call raise here instead of except
+            # TypeError as e / raise e, because the
+            # latter does not preserve the Gurobi stack
+            # trace
+            if not _is_numeric(option):
+                raise
+            model_or_env.setParam(key, float(option))
+
+
 @SolverFactory.register('gurobi_direct', doc='Direct python interface to Gurobi')
 class GurobiDirect(DirectSolver):
-    _verified_license = None
-    _import_messages = ''
+    """A direct interface to Gurobi using gurobipy.
+
+    :param manage_env: Set to True if this solver instance should create and
+        manage its own Gurobi environment (defaults to False)
+    :type manage_env: bool
+    :param options: Dictionary of Gurobi parameters to set
+    :type options: dict
+
+    If ``manage_env`` is set to True, the ``GurobiDirect`` object creates a local
+    Gurobi environment and manage all associated Gurobi resources. Importantly,
+    this enables Gurobi licenses to be freed and connections terminated when the
+    solver context is exited::
+
+        with SolverFactory('gurobi', solver_io='python', manage_env=True) as opt:
+            opt.solve(model)
+
+        # All Gurobi models and environments are freed
+
+    If ``manage_env`` is set to False (the default), the ``GurobiDirect`` object
+    uses the global default Gurobi environment::
+
+        with SolverFactory('gurobi', solver_io='python') as opt:
+            opt.solve(model)
+
+        # Only models created by `opt` are freed, the global default
+        # environment remains active
+
+    ``manage_env=True`` is required when setting license or connection parameters
+    programmatically. The ``options`` argument is used to pass parameters to the
+    Gurobi environment. For example, to connect to a Gurobi Cluster Manager::
+
+        options = {
+            "CSManager": "<url>",
+            "CSAPIAccessID": "<access-id>",
+            "CSAPISecret": "<api-key>",
+        }
+        with SolverFactory(
+            'gurobi', solver_io='python', manage_env=True, options=options
+        ) as opt:
+            opt.solve(model)  # Model solved on compute server
+        # Compute server connection terminated
+    """
+
     _name = None
     _version = 0
     _version_major = 0
+    _default_env_started = False
 
-    def __init__(self, **kwds):
+    def __init__(self, manage_env=False, **kwds):
         if 'type' not in kwds:
             kwds['type'] = 'gurobi_direct'
         super(GurobiDirect, self).__init__(**kwds)
@@ -121,7 +186,29 @@ def __init__(self, **kwds):
         # version of gurobi is supported (and stored as a class attribute)
         del self._version
 
+        self._manage_env = manage_env
+        self._env = None
+        self._env_options = None
+        self._solver_model = None
+
     def available(self, exception_flag=True):
+        """Returns True if the solver is available.
+
+        :param exception_flag: If True, raise an exception instead of returning
+            False if the solver is unavailable (defaults to False)
+        :type exception_flag: bool
+
+        In general, ``available()`` does not need to be called by the user, as
+        the check is run automatically when solving a model. However it is useful
+        for a simple retry loop when using a shared Gurobi license::
+
+            with SolverFactory('gurobi', solver_io='python') as opt:
+                while not available(exception_flag=False):
+                    time.sleep(1)
+                opt.solve(model)
+
+        """
+        # First check gurobipy is imported
         if not gurobipy_available:
             if exception_flag:
                 gurobipy.log_import_warning(logger=__name__)
@@ -129,28 +216,25 @@ def available(self, exception_flag=True):
                     "No Python bindings available for %s solver plugin" % (type(self),)
                 )
             return False
-        if self._verified_license is None:
-            with capture_output(capture_fd=True) as OUT:
-                try:
-                    # verify that we can get a Gurobi license
-                    # Gurobipy writes out license file information when creating
-                    # the environment
-                    m = gurobipy.Model()
-                    m.dispose()
-                    GurobiDirect._verified_license = True
-                except Exception as e:
-                    GurobiDirect._import_messages += (
-                        "\nCould not create Model - gurobi message=%s\n" % (e,)
-                    )
-                    GurobiDirect._verified_license = False
-            if OUT.getvalue():
-                GurobiDirect._import_messages += "\n" + OUT.getvalue()
-        if exception_flag and not self._verified_license:
-            logger.warning(GurobiDirect._import_messages)
+
+        # Ensure environment is started to check for a valid license
+        with capture_output(capture_fd=True) as OUT:
+            try:
+                self._init_env()
+                return True
+            except gurobipy.GurobiError as e:
+                msg = "Could not create Model - gurobi message=%s\n" % (e,)
+        if OUT.getvalue():
+            msg += "\n" + OUT.getvalue()
+        # Didn't return, so environment start failed
+        if exception_flag:
+            logger.warning(msg)
             raise ApplicationError(
-                "Could not create a gurobipy Model for %s solver plugin" % (type(self),)
+                "Could not create Model for %s solver plugin - gurobi message=%s"
+                % (type(self), msg)
             )
-        return self._verified_license
+        else:
+            return False
 
     def _apply_solver(self):
         StaleFlagManager.mark_all_as_stale()
@@ -165,38 +249,16 @@ def _apply_solver(self):
             self._solver_model.setParam('LogFile', self._log_file)
             print("Solver log file: " + self._log_file)
 
-        # Options accepted by gurobi (case insensitive):
-        # ['Cutoff', 'IterationLimit', 'NodeLimit', 'SolutionLimit', 'TimeLimit',
-        #  'FeasibilityTol', 'IntFeasTol', 'MarkowitzTol', 'MIPGap', 'MIPGapAbs',
-        #  'OptimalityTol', 'PSDTol', 'Method', 'PerturbValue', 'ObjScale', 'ScaleFlag',
-        #  'SimplexPricing', 'Quad', 'NormAdjust', 'BarIterLimit', 'BarConvTol',
-        #  'BarCorrectors', 'BarOrder', 'Crossover', 'CrossoverBasis', 'BranchDir',
-        #  'Heuristics', 'MinRelNodes', 'MIPFocus', 'NodefileStart', 'NodefileDir',
-        #  'NodeMethod', 'PumpPasses', 'RINS', 'SolutionNumber', 'SubMIPNodes', 'Symmetry',
-        #  'VarBranch', 'Cuts', 'CutPasses', 'CliqueCuts', 'CoverCuts', 'CutAggPasses',
-        #  'FlowCoverCuts', 'FlowPathCuts', 'GomoryPasses', 'GUBCoverCuts', 'ImpliedCuts',
-        #  'MIPSepCuts', 'MIRCuts', 'NetworkCuts', 'SubMIPCuts', 'ZeroHalfCuts', 'ModKCuts',
-        #  'Aggregate', 'AggFill', 'PreDual', 'DisplayInterval', 'IISMethod', 'InfUnbdInfo',
-        #  'LogFile', 'PreCrush', 'PreDepRow', 'PreMIQPMethod', 'PrePasses', 'Presolve',
-        #  'ResultFile', 'ImproveStartTime', 'ImproveStartGap', 'Threads', 'Dummy', 'OutputFlag']
-        for key, option in self.options.items():
-            # When options come from the pyomo command, all
-            # values are string types, so we try to cast
-            # them to a numeric value in the event that
-            # setting the parameter fails.
-            try:
-                self._solver_model.setParam(key, option)
-            except TypeError:
-                # we place the exception handling for
-                # checking the cast of option to a float in
-                # another function so that we can simply
-                # call raise here instead of except
-                # TypeError as e / raise e, because the
-                # latter does not preserve the Gurobi stack
-                # trace
-                if not _is_numeric(option):
-                    raise
-                self._solver_model.setParam(key, float(option))
+        # Only pass along changed parameters to the model
+        if self._env_options:
+            new_options = {
+                key: option
+                for key, option in self.options.items()
+                if key not in self._env_options or self._env_options[key] != option
+            }
+        else:
+            new_options = self.options
+        _set_options(self._solver_model, new_options)
 
         if self._version_major >= 5:
             for suffix in self._suffixes:
@@ -294,6 +356,101 @@ def _add_var(self, var):
 
         self._needs_updated = True
 
+    def close_global(self):
+        """Frees all Gurobi models used by this solver, and frees the global
+        default Gurobi environment.
+
+        The default environment is used by all ``GurobiDirect`` solvers started
+        with ``manage_env=False`` (the default). To guarantee that all Gurobi
+        resources are freed, all instantiated ``GurobiDirect`` solvers must also
+        be correctly closed.
+
+        The following example will free all Gurobi resources assuming the user did
+        not create any other models (e.g. via another ``GurobiDirect`` object with
+        ``manage_env=False``)::
+
+            opt = SolverFactory('gurobi', solver_io='python')
+            try:
+                opt.solve(model)
+            finally:
+                opt.close_global()
+            # All Gurobi models created by `opt` are freed and the default
+            # Gurobi environment is closed
+        """
+        self.close()
+        with capture_output(capture_fd=True):
+            gurobipy.disposeDefaultEnv()
+        GurobiDirect._default_env_started = False
+
+    def _init_env(self):
+        if self._manage_env:
+            # Ensure an environment is active for this instance
+            if self._env is None:
+                assert self._solver_model is None
+                env = gurobipy.Env(empty=True)
+                _set_options(env, self.options)
+                env.start()
+                # Successful start (no errors): store the environment
+                self._env = env
+                self._env_options = dict(self.options)
+        else:
+            # Ensure the (global) default env is started
+            if not GurobiDirect._default_env_started:
+                m = gurobipy.Model()
+                m.close()
+                GurobiDirect._default_env_started = True
+
+    def _create_model(self, model):
+        self._init_env()
+        if self._solver_model is not None:
+            self._solver_model.close()
+        if model.name is not None:
+            self._solver_model = gurobipy.Model(model.name, env=self._env)
+        else:
+            self._solver_model = gurobipy.Model(env=self._env)
+
+    def close(self):
+        """Frees local Gurobi resources used by this solver instance.
+
+        All Gurobi models created by the solver are freed. If the solver was
+        created with ``manage_env=True``, this method also closes the Gurobi
+        environment used by this solver instance. Calling ``.close()`` achieves
+        the same result as exiting the solver context (although using context
+        managers is preferred where possible)::
+
+            opt = SolverFactory('gurobi', solver_io='python', manage_env=True)
+            try:
+                opt.solve(model)
+            finally:
+                opt.close()
+            # Gurobi models and environments created by `opt` are freed
+
+        As with the context manager, if ``manage_env=False`` (the default) was
+        used, only the Gurobi models created by this solver are freed. The
+        default global Gurobi environment will still be active::
+
+            opt = SolverFactory('gurobi', solver_io='python')
+            try:
+                opt.solve(model)
+            finally:
+                opt.close()
+            # Gurobi models created by `opt` are freed; however the
+            # default/global Gurobi environment is still active
+        """
+
+        if self._solver_model is not None:
+            self._solver_model.close()
+            self._solver_model = None
+        if self._manage_env:
+            if self._env is not None:
+                self._env.close()
+                self._env = None
+                self._env_options = None
+
+    def __exit__(self, t, v, traceback):
+        super().__exit__(t, v, traceback)
+        self.close()
+
     def _set_instance(self, model, kwds={}):
         self._range_constraints = set()
         DirectOrPersistentSolver._set_instance(self, model, kwds)
@@ -302,10 +459,7 @@ def _set_instance(self, model, kwds={}):
         self._pyomo_var_to_solver_var_map = ComponentMap()
         self._solver_var_to_pyomo_var_map = ComponentMap()
         try:
-            if model.name is not None:
-                self._solver_model = gurobipy.Model(model.name)
-            else:
-                self._solver_model = gurobipy.Model()
+            self._create_model(model)
         except Exception:
             e = sys.exc_info()[1]
             msg = (


Test patch:
diff --git a/pyomo/solvers/tests/checks/test_gurobi_direct.py b/pyomo/solvers/tests/checks/test_gurobi_direct.py
new file mode 100644
--- /dev/null
+++ b/pyomo/solvers/tests/checks/test_gurobi_direct.py
@@ -0,0 +1,415 @@
+"""
+Tests for working with Gurobi environments. Some require a single-use license
+and are skipped if this isn't the case.
+"""
+
+import gc
+from unittest.mock import patch
+
+import pyomo.environ as pyo
+import pyomo.common.unittest as unittest
+from pyomo.common.errors import ApplicationError
+from pyomo.environ import SolverFactory, ConcreteModel
+from pyomo.opt import SolverStatus, TerminationCondition
+from pyomo.solvers.plugins.solvers.gurobi_direct import GurobiDirect
+
+
+try:
+    import gurobipy as gp
+
+    NO_LICENSE = gp.GRB.Error.NO_LICENSE
+    gurobipy_available = True
+except ImportError:
+    gurobipy_available = False
+
+
+def clean_up_global_state():
+    # Best efforts to dispose any gurobipy objects from previous tests
+    # which might keep the default environment active
+    gc.collect()
+    gp.disposeDefaultEnv()
+    # Reset flag to sync with default env state
+    GurobiDirect._default_env_started = False
+
+
+def single_use_license():
+    # Return true if the current license is valid and single-use
+    if not gurobipy_available:
+        return False
+    clean_up_global_state()
+    try:
+        with gp.Env():
+            try:
+                with gp.Env():
+                    # License allows multiple uses
+                    return False
+            except gp.GurobiError:
+                return True
+    except gp.GurobiError:
+        # No license available
+        return False
+
+
+class GurobiBase(unittest.TestCase):
+    # Base class ensures the global environment is cleaned up
+
+    def setUp(self):
+        clean_up_global_state()
+
+        # A simple model to solve
+        model = ConcreteModel()
+        model.x = pyo.Var([1, 2], domain=pyo.NonNegativeReals)
+        model.OBJ = pyo.Objective(expr=model.x[1] + model.x[2], sense=pyo.maximize)
+        model.Constraint1 = pyo.Constraint(expr=2 * model.x[1] + model.x[2] <= 1)
+        model.Constraint2 = pyo.Constraint(expr=model.x[1] + 2 * model.x[2] <= 1)
+        self.model = model
+
+    def tearDown(self):
+        clean_up_global_state()
+
+
+@unittest.skipIf(gurobipy_available, "gurobipy is installed, skip import test")
+class GurobiImportFailedTests(unittest.TestCase):
+    def test_gurobipy_not_installed(self):
+        # ApplicationError should be thrown if gurobipy is not available
+        model = ConcreteModel()
+        with SolverFactory("gurobi_direct") as opt:
+            with self.assertRaisesRegex(ApplicationError, "No Python bindings"):
+                opt.solve(model)
+
+
+@unittest.skipIf(not gurobipy_available, "gurobipy is not available")
+class GurobiParameterTests(GurobiBase):
+    # Test parameter handling at the model and environment level
+
+    def test_set_environment_parameters(self):
+        # Solver options should handle parameters which must be set before the
+        # environment is started (i.e. connection params, memory limits). This
+        # can only work with a managed env.
+
+        with SolverFactory(
+            "gurobi_direct", manage_env=True, options={"ComputeServer": "my-cs-url"}
+        ) as opt:
+            # Check that the error comes from an attempted connection, (i.e. error
+            # message reports the hostname) and not from setting the parameter after
+            # the environment is started.
+            with self.assertRaisesRegex(ApplicationError, "my-cs-url"):
+                opt.solve(self.model)
+
+    def test_set_once(self):
+        # Make sure parameters aren't set twice. If they are set on the
+        # environment, they shouldn't also be set on the model. This isn't an
+        # issue for most parameters, but some license parameters (e.g. WLS)
+        # will complain if set in both places.
+
+        envparams = {}
+        modelparams = {}
+
+        class TempEnv(gp.Env):
+            def setParam(self, param, value):
+                envparams[param] = value
+
+        class TempModel(gp.Model):
+            def setParam(self, param, value):
+                modelparams[param] = value
+
+        with patch("gurobipy.Env", new=TempEnv), patch("gurobipy.Model", new=TempModel):
+            with SolverFactory(
+                "gurobi_direct", options={"Method": 2, "MIPFocus": 1}, manage_env=True
+            ) as opt:
+                opt.solve(self.model, options={"MIPFocus": 2})
+
+        # Method should not be set again, but MIPFocus was changed.
+        # OutputFlag is explicitly set on the model.
+        assert envparams == {"Method": 2, "MIPFocus": 1}
+        assert modelparams == {"MIPFocus": 2, "OutputFlag": 0}
+
+    # Try an erroneous parameter setting to ensure parameters go through in all
+    # cases. Expect an error to indicate pyomo tried to set the parameter.
+
+    def test_param_changes_1(self):
+        # Default env: parameters set on model at solve time
+        with SolverFactory("gurobi_direct", options={"Method": -100}) as opt:
+            with self.assertRaisesRegex(gp.GurobiError, "Unable to set"):
+                opt.solve(self.model)
+
+    def test_param_changes_2(self):
+        # Note that this case throws an ApplicationError instead of a
+        # GurobiError since the bad parameter value prevents the environment
+        # from starting
+        # Managed env: parameters set on env at solve time
+        with SolverFactory(
+            "gurobi_direct", options={"Method": -100}, manage_env=True
+        ) as opt:
+            with self.assertRaisesRegex(ApplicationError, "Unable to set"):
+                opt.solve(self.model)
+
+    def test_param_changes_3(self):
+        # Default env: parameters passed to solve()
+        with SolverFactory("gurobi_direct") as opt:
+            with self.assertRaisesRegex(gp.GurobiError, "Unable to set"):
+                opt.solve(self.model, options={"Method": -100})
+
+    def test_param_changes_4(self):
+        # Managed env: parameters passed to solve()
+        with SolverFactory("gurobi_direct", manage_env=True) as opt:
+            with self.assertRaisesRegex(gp.GurobiError, "Unable to set"):
+                opt.solve(self.model, options={"Method": -100})
+
+
+@unittest.skipIf(not gurobipy_available, "gurobipy is not available")
+class GurobiEnvironmentTests(GurobiBase):
+    # Test handling of gurobi environments
+
+    def assert_optimal_result(self, results):
+        self.assertEqual(results.solver.status, SolverStatus.ok)
+        self.assertEqual(
+            results.solver.termination_condition, TerminationCondition.optimal
+        )
+
+    def test_init_default_env(self):
+        # available() calls with the default env shouldn't need a repeat check
+        with patch("gurobipy.Model") as PatchModel:
+            with SolverFactory("gurobi_direct") as opt:
+                opt.available()
+                opt.available()
+                PatchModel.assert_called_once_with()
+
+    def test_close_global(self):
+        # method releases the license and syncs the flag
+        with patch("gurobipy.Model") as PatchModel, patch(
+            "gurobipy.disposeDefaultEnv"
+        ) as patch_dispose:
+            with SolverFactory("gurobi_direct") as opt:
+                opt.available()
+                opt.available()
+                PatchModel.assert_called_once_with()
+            patch_dispose.assert_not_called()
+
+            # close default environment
+            opt.close_global()
+            patch_dispose.assert_called_once_with()
+
+        # _default_env_started flag was correctly synced, so available() is
+        # checked again
+        with patch("gurobipy.Model") as PatchModel, patch(
+            "gurobipy.disposeDefaultEnv"
+        ) as patch_dispose:
+            with SolverFactory("gurobi_direct") as opt:
+                opt.available()
+                opt.available()
+                PatchModel.assert_called_once_with()
+            patch_dispose.assert_not_called()
+
+    def test_persisted_license_failure(self):
+        # Gurobi error message should come through in the exception
+        # Failure to start an environment should not be persistent
+
+        with patch(
+            "gurobipy.Model", side_effect=gp.GurobiError(NO_LICENSE, "nolicense")
+        ):
+            with SolverFactory("gurobi_direct") as opt:
+                with self.assertRaisesRegex(ApplicationError, "nolicense"):
+                    opt.solve(self.model)
+
+        with SolverFactory("gurobi_direct") as opt:
+            results = opt.solve(self.model)
+            self.assert_optimal_result(results)
+
+    def test_persisted_license_failure_managed(self):
+        # Gurobi error message should come through in the exception
+        # Failure to start an environment should not be persistent
+
+        with patch("gurobipy.Env", side_effect=gp.GurobiError(NO_LICENSE, "nolicense")):
+            with SolverFactory("gurobi_direct", manage_env=True) as opt:
+                with self.assertRaisesRegex(ApplicationError, "nolicense"):
+                    opt.solve(self.model)
+
+        with SolverFactory("gurobi_direct", manage_env=True) as opt:
+            results = opt.solve(self.model)
+            self.assert_optimal_result(results)
+            self.assertEqual(results.solver.status, SolverStatus.ok)
+
+    def test_context(self):
+        # Context management should close the gurobi environment
+
+        with gp.Env() as use_env:
+            with patch("gurobipy.Env", return_value=use_env):
+                with SolverFactory("gurobi_direct", manage_env=True) as opt:
+                    results = opt.solve(self.model)
+                    self.assert_optimal_result(results)
+
+            # Environment was closed (cannot be restarted)
+            with self.assertRaises(gp.GurobiError):
+                use_env.start()
+
+    def test_close(self):
+        # Manual close() call should close the gurobi environment
+
+        with gp.Env() as use_env:
+            with patch("gurobipy.Env", return_value=use_env):
+                opt = SolverFactory("gurobi_direct", manage_env=True)
+                try:
+                    results = opt.solve(self.model)
+                    self.assert_optimal_result(results)
+                finally:
+                    opt.close()
+
+            # Environment was closed (cannot be restarted)
+            with self.assertRaises(gp.GurobiError):
+                use_env.start()
+
+    @unittest.skipIf(single_use_license(), reason="test requires multi-use license")
+    def test_multiple_solvers_managed(self):
+        # Multiple managed solvers will create their own envs
+
+        with SolverFactory("gurobi_direct", manage_env=True) as opt1, SolverFactory(
+            "gurobi_direct", manage_env=True
+        ) as opt2:
+            results1 = opt1.solve(self.model)
+            self.assert_optimal_result(results1)
+            results2 = opt2.solve(self.model)
+            self.assert_optimal_result(results2)
+
+    def test_multiple_solvers_nonmanaged(self):
+        # Multiple solvers will share the default environment
+
+        with SolverFactory("gurobi_direct") as opt1, SolverFactory(
+            "gurobi_direct"
+        ) as opt2:
+            results1 = opt1.solve(self.model)
+            self.assert_optimal_result(results1)
+            results2 = opt2.solve(self.model)
+            self.assert_optimal_result(results2)
+
+    @unittest.skipIf(single_use_license(), reason="test requires multi-use license")
+    def test_managed_env(self):
+        # Test that manage_env=True creates its own environment
+
+        # Set parameters on the default environment
+        gp.setParam("IterationLimit", 100)
+
+        # On the patched environment, solve times out due to parameter setting
+        with gp.Env(params={"IterationLimit": 0, "Presolve": 0}) as use_env, patch(
+            "gurobipy.Env", return_value=use_env
+        ):
+            with SolverFactory("gurobi_direct", manage_env=True) as opt:
+                results = opt.solve(self.model)
+                self.assertEqual(results.solver.status, SolverStatus.aborted)
+                self.assertEqual(
+                    results.solver.termination_condition,
+                    TerminationCondition.maxIterations,
+                )
+
+    def test_nonmanaged_env(self):
+        # Test that manage_env=False (default) uses the default environment
+
+        # Set parameters on the default environment
+        gp.setParam("IterationLimit", 0)
+        gp.setParam("Presolve", 0)
+
+        # Using the default env, solve times out due to parameter setting
+        with SolverFactory("gurobi_direct") as opt:
+            results = opt.solve(self.model)
+            self.assertEqual(results.solver.status, SolverStatus.aborted)
+            self.assertEqual(
+                results.solver.termination_condition, TerminationCondition.maxIterations
+            )
+
+
+@unittest.skipIf(not gurobipy_available, "gurobipy is not available")
+@unittest.skipIf(not single_use_license(), reason="test needs a single use license")
+class GurobiSingleUseTests(GurobiBase):
+    # Integration tests for Gurobi single-use licenses (useful for checking all Gurobi
+    # environments were correctly freed). These tests are not run in pyomo's CI. Each
+    # test in this class has an equivalent in GurobiEnvironmentTests which tests the
+    # same behaviour via monkey patching.
+
+    def test_persisted_license_failure(self):
+        # Solver should allow retries to start the environment, instead of
+        # persisting the same failure (default env).
+
+        with SolverFactory("gurobi_direct") as opt:
+            with gp.Env():
+                # Expected to fail: there is another environment open so the
+                # default env cannot be started.
+                with self.assertRaises(ApplicationError):
+                    opt.solve(self.model)
+            # Should not raise an error, since the other environment has been freed.
+            opt.solve(self.model)
+
+    def test_persisted_license_failure_managed(self):
+        # Solver should allow retries to start the environment, instead of
+        # persisting the same failure (managed env).
+
+        with SolverFactory("gurobi_direct", manage_env=True) as opt:
+            with gp.Env():
+                # Expected to fail: there is another environment open so the
+                # default env cannot be started.
+                with self.assertRaises(ApplicationError):
+                    opt.solve(self.model)
+            # Should not raise an error, since the other environment has been freed.
+            opt.solve(self.model)
+
+    def test_context(self):
+        # Context management should close the gurobi environment.
+        with SolverFactory("gurobi_direct", manage_env=True) as opt:
+            opt.solve(self.model)
+
+        # Environment closed, so another can be created
+        with gp.Env():
+            pass
+
+    def test_close(self):
+        # Manual close() call should close the gurobi environment.
+        opt = SolverFactory("gurobi_direct", manage_env=True)
+        try:
+            opt.solve(self.model)
+        finally:
+            opt.close()
+
+        # Environment closed, so another can be created
+        with gp.Env():
+            pass
+
+    def test_multiple_solvers(self):
+        # One environment per solver would break this pattern. Test that
+        # global env is still used by default (manage_env=False)
+
+        with SolverFactory("gurobi_direct") as opt1, SolverFactory(
+            "gurobi_direct"
+        ) as opt2:
+            opt1.solve(self.model)
+            opt2.solve(self.model)
+
+    def test_multiple_models_leaky(self):
+        # Make sure all models are closed explicitly by the GurobiDirect instance.
+
+        with SolverFactory("gurobi_direct", manage_env=True) as opt:
+            opt.solve(self.model)
+            # Leak a model reference, then create a new model.
+            # Pyomo should close the old model since it is no longed needed.
+            tmp = opt._solver_model
+            opt.solve(self.model)
+
+        # Context manager properly closed all models and environments
+        with gp.Env():
+            pass
+
+    def test_close_global(self):
+        # If using the default environment, calling the close_global
+        # classmethod closes the environment, providing any other solvers
+        # have also been closed.
+
+        opt1 = SolverFactory("gurobi_direct")
+        opt2 = SolverFactory("gurobi_direct")
+        try:
+            opt1.solve(self.model)
+            opt2.solve(self.model)
+        finally:
+            opt1.close()
+            opt2.close_global()
+
+        # Context closed AND close_global called
+        with gp.Env():
+            pass
diff --git a/pyomo/solvers/tests/checks/test_gurobi_persistent.py b/pyomo/solvers/tests/checks/test_gurobi_persistent.py
--- a/pyomo/solvers/tests/checks/test_gurobi_persistent.py
+++ b/pyomo/solvers/tests/checks/test_gurobi_persistent.py
@@ -16,7 +16,9 @@
 try:
     import gurobipy
 
-    m = gurobipy.Model()
+    with gurobipy.Env():
+        pass
+
     gurobipy_available = True
 except:
     gurobipy_available = False