Repository: Pyomo/pyomo

Issue:
Test failure in kernel with Dill 0.3.5.1
## Summary

Looks like the latest release of Dill on May 20, 2022 has caused a test failure in pyomo/core/tests/unit/kernel/test_parameter.py. Currently seeing this test failure across all operating systems and all Python versions.

### Error Message
```
_______________________ Test_functional_value.test_dill ________________________

self = <pyomo.core.tests.unit.kernel.test_parameter.Test_functional_value testMethod=test_dill>

    @unittest.skipIf(not has_dill,
                     "The dill module is not available")
    def test_dill(self):
        p = parameter(1)
        f = functional_value(lambda: p())
        self.assertEqual(f(), 1)
        fup = dill.loads(
>           dill.dumps(f))

pyomo/core/tests/unit/kernel/test_parameter.py:190: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/site-packages/dill/_dill.py:364: in dumps
    dump(obj, file, protocol, byref, fmode, recurse, **kwds)#, strictio)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/site-packages/dill/_dill.py:336: in dump
    Pickler(file, protocol, **_kwds).dump(obj)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/site-packages/dill/_dill.py:620: in dump
    StockPickler.dump(self, obj)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:437: in dump
    self.save(obj)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:549: in save
    self.save_reduce(obj=obj, *rv)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:662: in save_reduce
    save(state)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:504: in save
    f(self, obj) # Call unbound method with explicit self
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/site-packages/dill/_dill.py:1251: in save_module_dict
    StockPickler.save_dict(pickler, obj)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:859: in save_dict
    self._batch_setitems(obj.items())
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:885: in _batch_setitems
    save(v)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:504: in save
    f(self, obj) # Call unbound method with explicit self
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/site-packages/dill/_dill.py:1966: in save_function
    ), state), obj=obj, postproc_list=postproc_list)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/site-packages/dill/_dill.py:1159: in _save_with_postproc
    pickler.save_reduce(*reduction)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:637: in save_reduce
    save(func)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:549: in save
    self.save_reduce(obj=obj, *rv)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:638: in save_reduce
    save(args)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:504: in save
    f(self, obj) # Call unbound method with explicit self
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:774: in save_tuple
    save(element)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:504: in save
    f(self, obj) # Call unbound method with explicit self
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/site-packages/dill/_dill.py:1770: in save_module
    pickler.save_global(obj, name="_dill")
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:952: in save_global
    module_name = whichmodule(obj, name)
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:313: in whichmodule
    if _getattribute(module, name)[0] is obj:
/opt/hostedtoolcache/Python/3.7.13/x64/lib/python3.7/pickle.py:296: in _getattribute
    obj = getattr(obj, subpath)
pyomo/common/dependencies.py:146: in __getattr__
    return getattr(_mod, attr)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <pyomo.common.dependencies.ModuleUnavailable object at 0x7ff34e8b5cd0>
attr = '_dill'

    def __getattr__(self, attr):
        if attr in ModuleUnavailable._getattr_raises_attributeerror:
            raise AttributeError("'%s' object has no attribute '%s'"
                                 % (type(self).__name__, attr))
>       raise DeferredImportError(self._moduleunavailable_message())
E       pyomo.common.dependencies.DeferredImportError: The pympler module (an optional Pyomo dependency) failed to import: ModuleNotFoundError: No module named 'pympler'

pyomo/common/dependencies.py:69: DeferredImportError
------- generated xml file: /home/runner/work/pyomo/pyomo/TEST-pyomo.xml -------
=========================== short test summary info ============================
FAILED pyomo/core/tests/unit/kernel/test_parameter.py::Test_functional_value::test_dill
```


Discussion:

Resolving patch:
diff --git a/pyomo/common/dependencies.py b/pyomo/common/dependencies.py
--- a/pyomo/common/dependencies.py
+++ b/pyomo/common/dependencies.py
@@ -51,10 +51,16 @@ class ModuleUnavailable(object):
         The module name that originally attempted the import
     """
 
-    # We need special handling for Sphinx here, as it will look for the
-    # __sphinx_mock__ attribute on all module-level objects, and we need
-    # that to raise an AttributeError and not a DeferredImportError
-    _getattr_raises_attributeerror = {'__sphinx_mock__',}
+    _getattr_raises_attributeerror = {
+        # We need special handling for Sphinx here, as it will look for the
+        # __sphinx_mock__ attribute on all module-level objects, and we need
+        # that to raise an AttributeError and not a DeferredImportError
+        '__sphinx_mock__',
+        # We need special handling for dill as well, as dill attempts to
+        # pickle module globals by looking for the '_dill' attribute on
+        # all global objects.
+        '_dill',
+    }
 
     def __init__(self, name, message, version_error, import_error, package):
         self.__name__ = name
@@ -68,6 +74,17 @@ def __getattr__(self, attr):
                                  % (type(self).__name__, attr))
         raise DeferredImportError(self._moduleunavailable_message())
 
+    def __getstate__(self):
+        return (self.__name__, self._moduleunavailable_info_)
+
+    def __setstate__(self, state):
+        self.__name__, self._moduleunavailable_info_ = state
+
+    # Included because recent dill picklers look for the mro() when
+    # detecting numpy types
+    def mro(self):
+        return [ModuleUnavailable, object]
+
     def _moduleunavailable_message(self, msg=None):
         _err, _ver, _imp, _package = self._moduleunavailable_info_
         if msg is None:
@@ -145,6 +162,18 @@ def __getattr__(self, attr):
                 _mod = getattr(_mod, _sub)
         return getattr(_mod, attr)
 
+    def __getstate__(self):
+        return self.__dict__
+
+    def __setstate__(self, state):
+        for k, v in state.items():
+            super().__setattr__(k, v)
+
+    # Included because recent dill picklers look for the mro() when
+    # detecting numpy types
+    def mro(self):
+        return [DeferredImportModule, object]
+
 
 class _DeferredImportIndicatorBase(object):
     def __and__(self, other):


Test patch:
diff --git a/pyomo/common/tests/deps.py b/pyomo/common/tests/deps.py
--- a/pyomo/common/tests/deps.py
+++ b/pyomo/common/tests/deps.py
@@ -26,6 +26,11 @@
 bogus, bogus_available \
     = attempt_import('nonexisting.module.bogus', defer_check=True)
 
+pkl_test, pkl_available = attempt_import(
+    'nonexisting.module.pickle_test',
+    deferred_submodules=['submod'], defer_check=True
+)
+
 pyo, pyo_available = attempt_import(
     'pyomo', alt_names=['pyo'],
     deferred_submodules={'version': None,
diff --git a/pyomo/common/tests/test_dependencies.py b/pyomo/common/tests/test_dependencies.py
--- a/pyomo/common/tests/test_dependencies.py
+++ b/pyomo/common/tests/test_dependencies.py
@@ -18,7 +18,8 @@
 from pyomo.common.dependencies import (
     attempt_import, ModuleUnavailable, DeferredImportModule,
     DeferredImportIndicator, DeferredImportError,
-    _DeferredAnd, _DeferredOr, check_min_version
+    _DeferredAnd, _DeferredOr, check_min_version,
+    dill, dill_available
 )
 
 import pyomo.common.tests.dep_mod as dep_mod
@@ -48,6 +49,26 @@ def test_import_error(self):
                 "attribute '__sphinx_mock__'"):
             module_obj.__sphinx_mock__
 
+    @unittest.skipUnless(dill_available, "Test requires dill module")
+    def test_pickle(self):
+        self.assertIs(deps.pkl_test.__class__, DeferredImportModule)
+        # Pickle the DeferredImportModule class
+        pkl = dill.dumps(deps.pkl_test)
+        deps.new_pkl_test = dill.loads(pkl)
+        self.assertIs(deps.pkl_test.__class__, deps.new_pkl_test.__class__)
+        self.assertIs(deps.new_pkl_test.__class__, DeferredImportModule)
+        self.assertIsNot(deps.pkl_test, deps.new_pkl_test)
+        self.assertIn('submod', deps.new_pkl_test.__dict__)
+        with self.assertRaisesRegex(
+                DeferredImportError, 'nonexisting.module.pickle_test module'):
+            deps.new_pkl_test.try_to_call_a_method()
+        # Pickle the ModuleUnavailable class
+        self.assertIs(deps.new_pkl_test.__class__, ModuleUnavailable)
+        pkl = dill.dumps(deps.new_pkl_test)
+        new_pkl_test_2 = dill.loads(pkl)
+        self.assertIs(deps.new_pkl_test.__class__, new_pkl_test_2.__class__)
+        self.assertIsNot(deps.new_pkl_test, new_pkl_test_2)
+        self.assertIs(new_pkl_test_2.__class__, ModuleUnavailable)
 
     def test_import_success(self):
         module_obj, module_available = attempt_import(