1#  ___________________________________________________________________________
2#
3#  Pyomo: Python Optimization Modeling Objects
4#  Copyright 2017 National Technology and Engineering Solutions of Sandia, LLC
5#  Under the terms of Contract DE-NA0003525 with National Technology and
6#  Engineering Solutions of Sandia, LLC, the U.S. Government retains certain
7#  rights in this software.
8#  This software is distributed under the 3-clause BSD License.
9#  ___________________________________________________________________________
10
11from collections.abc import Mapping
12import inspect
13import importlib
14import logging
15import sys
16
17from .deprecation import (
18    deprecated, deprecation_warning, in_testing_environment,
19)
20from . import numeric_types
21
22class DeferredImportError(ImportError):
23    pass
24
25class ModuleUnavailable(object):
26    """Mock object that raises a DeferredImportError upon attribute access
27
28    This object is returned by :py:func:`attempt_import()` in lieu of
29    the module in the case that the module import fails.  Any attempts
30    to access attributes on this object will raise a DeferredImportError
31    exception.
32
33    Parameters
34    ----------
35    name: str
36        The module name that was being imported
37
38    message: str
39        The string message to return in the raised exception
40
41    version_error: str
42        A string to add to the message if the module failed to import because
43        it did not match the required version
44
45    import_error: str
46        A string to add to the message documenting the Exception
47        raised when the module failed to import.
48
49    """
50
51    # We need special handling for Sphinx here, as it will look for the
52    # __sphinx_mock__ attribute on all module-level objects, and we need
53    # that to raise an AttributeError and not a DeferredImportError
54    _getattr_raises_attributeerror = {'__sphinx_mock__',}
55
56    def __init__(self, name, message, version_error, import_error):
57        self.__name__ = name
58        self._moduleunavailable_info_ = (message, version_error, import_error)
59
60    def __getattr__(self, attr):
61        if attr in ModuleUnavailable._getattr_raises_attributeerror:
62            raise AttributeError("'%s' object has no attribute '%s'"
63                                 % (type(self).__name__, attr))
64        raise DeferredImportError(self._moduleunavailable_message())
65
66    def _moduleunavailable_message(self, msg=None):
67        _err, _ver, _imp = self._moduleunavailable_info_
68        if msg is None:
69            msg = _err
70        if _imp:
71            if not msg or not str(msg):
72                msg = (
73                    "The %s module (an optional Pyomo dependency) " \
74                    "failed to import: %s" % (self.__name__, _imp)
75                )
76            else:
77                msg = "%s (import raised %s)" % (msg, _imp,)
78        if _ver:
79            if not msg or not str(msg):
80                msg = "The %s module %s" % (self.__name__, _ver)
81            else:
82                msg = "%s (%s)" % (msg, _ver,)
83        return msg
84
85    def log_import_warning(self, logger='pyomo', msg=None):
86        """Log the import error message to the specified logger
87
88        This will log the the import error message to the specified
89        logger.  If ``msg=`` is specified, it will override the default
90        message passed to this instance of
91        :py:class:`ModuleUnavailable`.
92
93        """
94        logging.getLogger(logger).warning(self._moduleunavailable_message(msg))
95
96    @deprecated("use :py:class:`log_import_warning()`", version='6.0')
97    def generate_import_warning(self, logger='pyomo.common'):
98        self.log_import_warning(logger)
99
100
101class DeferredImportModule(object):
102    """Mock module object to support the deferred import of a module.
103
104    This object is returned by :py:func:`attempt_import()` in lieu of
105    the module when :py:func:`attempt_import()` is called with
106    ``defer_check=True``.  Any attempts to access attributes on this
107    object will trigger the actual module import and return either the
108    appropriate module attribute or else if the module import fails,
109    raise a DeferredImportError exception.
110
111    """
112    def __init__(self, indicator, deferred_submodules, submodule_name):
113        self._indicator_flag = indicator
114        self._submodule_name = submodule_name
115        self.__file__ = None # Disable nose's coverage of this module
116        self.__spec__ = None # Indicate that this is not a "real" module
117
118        if not deferred_submodules:
119            return
120        if submodule_name is None:
121            submodule_name = ''
122        for name in deferred_submodules:
123            if not name.startswith(submodule_name + '.'):
124                continue
125            _local_name = name[(1+len(submodule_name)):]
126            if '.' in _local_name:
127                continue
128            setattr(self, _local_name, DeferredImportModule(
129                indicator, deferred_submodules,
130                submodule_name + '.' + _local_name))
131
132    def __getattr__(self, attr):
133        self._indicator_flag.resolve()
134        _mod = self._indicator_flag._module
135        if self._submodule_name:
136            for _sub in self._submodule_name[1:].split('.'):
137                _mod = getattr(_mod, _sub)
138        return getattr(_mod, attr)
139
140
141class _DeferredImportIndicatorBase(object):
142    def __and__(self, other):
143        return _DeferredAnd(self, other)
144
145    def __or__(self, other):
146        return _DeferredOr(self, other)
147
148    def __rand__(self, other):
149        return _DeferredAnd(other, self)
150
151    def __ror__(self, other):
152        return _DeferredOr(other, self)
153
154
155class DeferredImportIndicator(_DeferredImportIndicatorBase):
156    """Placeholder indicating if an import was successful.
157
158    This object serves as a placeholder for the Boolean indicator if a
159    deferred module import was successful.  Casting this instance to
160    bool will cause the import to be attempted.  The actual import logic
161    is here and not in the DeferredImportModule to reduce the number of
162    attributes on the DeferredImportModule.
163
164    ``DeferredImportIndicator`` supports limited logical expressions
165    using the ``&`` (and) and ``|`` (or) binary operators.  Creating
166    these expressions does not trigger the import of the corresponding
167    :py:class:`DeferredImportModule` instances, although casting the
168    resulting expression to ``bool()`` will trigger any relevant
169    imports.
170
171    """
172
173    def __init__(self, name, error_message, catch_exceptions,
174                 minimum_version, original_globals, callback, importer,
175                 deferred_submodules):
176        self._names = [name]
177        for _n in tuple(self._names):
178            if '.' in _n:
179                self._names.append(_n.split('.')[-1])
180        self._error_message = error_message
181        self._catch_exceptions = catch_exceptions
182        self._minimum_version = minimum_version
183        self._original_globals = original_globals
184        self._callback = callback
185        self._importer = importer
186        self._module = None
187        self._available = None
188        self._deferred_submodules = deferred_submodules
189
190    def __bool__(self):
191        self.resolve()
192        return self._available
193
194    def resolve(self):
195        # Only attempt the import once, then cache some form of result
196        if self._module is None:
197            try:
198                self._module, self._available = attempt_import(
199                    name=self._names[0],
200                    error_message=self._error_message,
201                    catch_exceptions=self._catch_exceptions,
202                    minimum_version=self._minimum_version,
203                    callback=self._callback,
204                    importer=self._importer,
205                    defer_check=False,
206                )
207            except Exception as e:
208                # make sure that we cache the result
209                self._module = ModuleUnavailable(
210                    self._names[0],
211                    "Exception raised when importing %s" % (self._names[0],),
212                    None,
213                    "%s: %s" % (type(e).__name__, e),
214                )
215                self._available = False
216                raise
217
218            # If this module was not found, then we need to check for
219            # deferred submodules and resolve them as well
220            if self._deferred_submodules and \
221               type(self._module) is ModuleUnavailable:
222                info = self._module._moduleunavailable_info_
223                for submod in self._deferred_submodules:
224                    refmod = self._module
225                    for name in submod.split('.')[1:]:
226                        try:
227                            refmod = getattr(refmod, name)
228                        except DeferredImportError:
229                            setattr(refmod, name, ModuleUnavailable(
230                                refmod.__name__+submod, *info))
231                            refmod = getattr(refmod, name)
232
233            # Replace myself in the original globals() where I was
234            # declared
235            self.replace_self_in_globals(self._original_globals)
236
237        # Replace myself in the caller globals (to avoid calls to
238        # this method in the future)
239        _frame = inspect.currentframe().f_back
240        while _frame.f_globals is globals():
241            _frame = _frame.f_back
242        self.replace_self_in_globals(_frame.f_globals)
243
244    def replace_self_in_globals(self, _globals):
245        for k,v in _globals.items():
246            if v is self:
247                _globals[k] = self._available
248            elif v.__class__ is DeferredImportModule and \
249                 v._indicator_flag is self:
250                if v._submodule_name is None:
251                    _globals[k] = self._module
252                else:
253                    _mod_path = v._submodule_name.split('.')[1:]
254                    _mod = self._module
255                    for _sub in _mod_path:
256                        _mod = getattr(_mod, _sub)
257                    _globals[k] = _mod
258
259
260class _DeferredAnd(_DeferredImportIndicatorBase):
261    def __init__(self, a, b):
262        self._a = a
263        self._b = b
264
265    def __bool__(self):
266        return bool(self._a) and bool(self._b)
267
268
269class _DeferredOr(_DeferredImportIndicatorBase):
270    def __init__(self, a, b):
271        self._a = a
272        self._b = b
273
274    def __bool__(self):
275        return bool(self._a) or bool(self._b)
276
277
278def check_min_version(module, min_version):
279    if isinstance(module, DeferredImportModule):
280        indicator = module._indicator_flag
281        indicator.resolve()
282        if indicator._available:
283            module = indicator._module
284        else:
285            return False
286    try:
287        from packaging import version as _version
288        _parser = _version.parse
289    except ImportError:
290        # pkg_resources is an order of magnitude slower to import than
291        # packaging.  Only use it if the preferred (but optional)
292        # packaging library is not present
293        from pkg_resources import parse_version as _parser
294
295    version = getattr(module, '__version__', '0.0.0')
296    return _parser(min_version) <= _parser(version)
297
298
299def attempt_import(name, error_message=None, only_catch_importerror=None,
300                   minimum_version=None, alt_names=None, callback=None,
301                   importer=None, defer_check=True, deferred_submodules=None,
302                   catch_exceptions=None):
303    """Attempt to import the specified module.
304
305    This will attempt to import the specified module, returning a
306    ``(module, available)`` tuple.  If the import was successful, ``module``
307    will be the imported module and ``available`` will be True.  If the
308    import results in an exception, then ``module`` will be an instance of
309    :py:class:`ModuleUnavailable` and ``available`` will be False
310
311    The following
312
313    .. doctest::
314
315       >>> from pyomo.common.dependencies import attempt_import
316       >>> numpy, numpy_available = attempt_import('numpy')
317
318    Is roughly equivalent to
319
320    .. doctest::
321
322       >>> from pyomo.common.dependencies import ModuleUnavailable
323       >>> try:
324       ...     import numpy
325       ...     numpy_available = True
326       ... except ImportError as e:
327       ...     numpy = ModuleUnavailable('numpy', 'Numpy is not available',
328       ...                               '', str(e))
329       ...     numpy_available = False
330
331    The import can be "deferred" until the first time the code either
332    attempts to access the module or checks the Boolean value of the
333    available flag.  This allows optional dependencies to be declared at
334    the module scope but not imported until they are actually used by
335    the module (thereby speeding up the initial package import).
336    Deferred imports are handled by two helper classes
337    (:py:class:`DeferredImportModule` and
338    :py:class:`DeferredImportIndicator`).  Upon actual import,
339    :py:meth:`DeferredImportIndicator.resolve()` attempts to replace
340    those objects (in both the local and original global namespaces)
341    with the imported module and Boolean flag so that subsequent uses of
342    the module do not incur any overhead due to the delayed import.
343
344    Parameters
345    ----------
346    name: str
347        The name of the module to import
348
349    error_message: str, optional
350        The message for the exception raised by :py:class:`ModuleUnavailable`
351
352    only_catch_importerror: bool, optional
353        DEPRECATED: use catch_exceptions instead or only_catch_importerror.
354        If True (the default), exceptions other than ``ImportError`` raised
355        during module import will be reraised.  If False, any exception
356        will result in returning a :py:class:`ModuleUnavailable` object.
357        (deprecated in version 5.7.3)
358
359    minimum_version: str, optional
360        The minimum acceptable module version (retrieved from
361        ``module.__version__``)
362
363    alt_names: list, optional
364        DEPRECATED: alt_names no longer needs to be specified and is ignored.
365        A list of common alternate names by which to look for this
366        module in the ``globals()`` namespaces.  For example, the alt_names
367        for NumPy would be ``['np']``.  (deprecated in version 6.0)
368
369    callback: function, optional
370        A function with the signature "``fcn(module, available)``" that
371        will be called after the import is first attempted.
372
373    importer: function, optional
374        A function that will perform the import and return the imported
375        module (or raise an :py:class:`ImportError`).  This is useful
376        for cases where there are several equivalent modules and you
377        want to import/return the first one that is available.
378
379    defer_check: bool, optional
380        If True (the default), then the attempted import is deferred
381        until the first use of either the module or the availability
382        flag.  The method will return instances of DeferredImportModule
383        and DeferredImportIndicator.
384
385    deferred_submodules: Iterable[str], optional
386        If provided, an iterable of submodule names within this module
387        that can be accessed without triggering a deferred import of
388        this module.  For example, this module uses
389        ``deferred_submodules=['pyplot', 'pylab']`` for ``matplotlib``.
390
391    catch_exceptions: Iterable[Exception], optional
392        If provide, this is the list of exceptions that will be caught
393        when importing the target module, resulting in
394        ``attempt_import`` returning a :py:class:`ModuleUnavailable`
395        instance.  The default is to only catch :py:class:`ImportError`.
396        This is useful when a module can regularly return additional
397        exceptions during import.
398
399    Returns
400    -------
401    : module
402        the imported module, or an instance of
403        :py:class:`ModuleUnavailable`, or an instance of
404        :py:class:`DeferredImportModule`
405    : bool
406        Boolean indicating if the module import succeeded or an instance
407        of :py:class:`DeferredImportIndicator`
408
409    """
410    if alt_names is not None:
411        deprecation_warning('alt_names=%s no longer needs to be specified '
412                            'and is ignored' % (alt_names,), version='6.0')
413
414    if only_catch_importerror is not None:
415        deprecation_warning(
416            "only_catch_importerror is deprecated.  Pass exceptions to "
417            "catch using the catch_exceptions argument", version='5.7.3')
418        if catch_exceptions is not None:
419            raise ValueError("Cannot specify both only_catch_importerror "
420                             "and catch_exceptions")
421        if only_catch_importerror:
422            catch_exceptions = (ImportError,)
423        else:
424            catch_exceptions = (ImportError, Exception)
425    if catch_exceptions is None:
426        catch_exceptions = (ImportError,)
427
428    # If we are going to defer the check until later, return the
429    # deferred import module object
430    if defer_check:
431        if deferred_submodules:
432            if isinstance(deferred_submodules, Mapping):
433                deprecation_warning(
434                    'attempt_import(): deferred_submodules takes an iterable '
435                    'and not a mapping (the alt_names supplied by the mapping '
436                    'are no longer needed and are ignored).', version='6.0')
437                deferred_submodules = list(deferred_submodules)
438
439            # Ensures all names begin with '.'
440            #
441            # Fill in any missing submodules.  For example, if a user
442            # provides {'foo.bar.baz': ['bz']}, then expand the dict to
443            # {'.foo': None, '.foo.bar': None, '.foo.bar.baz': ['bz']}
444            deferred = []
445            for _submod in deferred_submodules:
446                if _submod[0] != '.':
447                    _submod = '.' + _submod
448                _mod_path = _submod.split('.')
449                for i in range(len(_mod_path)):
450                    _test_mod = '.'.join(_mod_path[:i])
451                    if _test_mod not in deferred:
452                        deferred.append(_test_mod)
453                deferred.append(_submod)
454            deferred = [_ for _ in deferred if _]
455        else:
456            deferred = None
457
458        indicator = DeferredImportIndicator(
459            name=name,
460            error_message=error_message,
461            catch_exceptions=catch_exceptions,
462            minimum_version=minimum_version,
463            original_globals=inspect.currentframe().f_back.f_globals,
464            callback=callback,
465            importer=importer,
466            deferred_submodules=deferred)
467        return DeferredImportModule(indicator, deferred, None), indicator
468
469    if deferred_submodules:
470        raise ValueError(
471            "deferred_submodules is only valid if defer_check==True")
472
473    import_error = None
474    version_error = None
475    try:
476        if importer is None:
477            module = importlib.import_module(name)
478        else:
479            module = importer()
480        if ( minimum_version is None
481             or check_min_version(module, minimum_version) ):
482            if callback is not None:
483                callback(module, True)
484            return module, True
485        else:
486            version = getattr(module, '__version__', 'UNKNOWN')
487            version_error = (
488                "version %s does not satisfy the minimum version %s"
489                % (version, minimum_version))
490    except catch_exceptions as e:
491        import_error = "%s: %s" % (type(e).__name__, e)
492
493    module = ModuleUnavailable(name, error_message, version_error, import_error)
494    if callback is not None:
495        callback(module, False)
496    return module, False
497
498
499def declare_deferred_modules_as_importable(globals_dict):
500    """Make all DeferredImportModules in ``globals_dict`` importable
501
502    This function will go throught the specified ``globals_dict``
503    dictionary and add any instances of :py:class:`DeferredImportModule`
504    that it finds (and any of their deferred submodules) to
505    ``sys.modules`` so that the modules can be imported through the
506    ``globals_dict`` namespace.
507
508    For example, ``pyomo/common/dependencies.py`` declares:
509
510    .. doctest::
511       :hide:
512
513       >>> from pyomo.common.dependencies import (
514       ...     attempt_import, _finalize_scipy, __dict__ as dep_globals,
515       ...     declare_deferred_modules_as_importable, )
516       >>> # Sphinx does not provide a proper globals()
517       >>> def globals(): return dep_globals
518
519    .. doctest::
520
521       >>> scipy, scipy_available = attempt_import(
522       ...     'scipy', callback=_finalize_scipy,
523       ...     deferred_submodules=['stats', 'sparse', 'spatial', 'integrate'])
524       >>> declare_deferred_modules_as_importable(globals())
525
526    Which enables users to use:
527
528    .. doctest::
529
530       >>> import pyomo.common.dependencies.scipy.sparse as spa
531
532    If the deferred import has not yet been triggered, then the
533    :py:class:`DeferredImportModule` is returned and named ``spa``.
534    However, if the import has already been triggered, then ``spa`` will
535    either be the ``scipy.sparse`` module, or a
536    :py:class:`ModuleUnavailable` instance.
537
538    """
539    _global_name = globals_dict['__name__'] + '.'
540    deferred = list((k, v) for k, v in globals_dict.items()
541                    if type(v) is DeferredImportModule )
542    while deferred:
543        name, mod = deferred.pop(0)
544        mod.__path__ = None
545        mod.__spec__ = None
546        sys.modules[_global_name + name] = mod
547        deferred.extend((name + '.' + k, v) for k, v in mod.__dict__.items()
548                        if type(v) is DeferredImportModule )
549
550
551#
552# Common optional dependencies used throughout Pyomo
553#
554
555yaml_load_args = {}
556def _finalize_yaml(module, available):
557    # Recent versions of PyYAML issue warnings if the Loader argument is
558    # not set
559    if available and hasattr(module, 'SafeLoader'):
560        yaml_load_args['Loader'] = module.SafeLoader
561
562def _finalize_scipy(module, available):
563    if available:
564        # Import key subpackages that we will want to assume are present
565        import scipy.stats
566        # As of scipy 1.6.0, importing scipy.stats causes the following
567        # to be automatically imported.  However, we will still
568        # explicitly import them here to guard against potential future
569        # changes in scipy.
570        import scipy.integrate
571        import scipy.sparse
572        import scipy.spatial
573
574def _finalize_pympler(module, available):
575    if available:
576        # Import key subpackages that we will want to assume are present
577        import pympler.muppy
578
579def _finalize_matplotlib(module, available):
580    if not available:
581        return
582    # You must switch matplotlib backends *before* importing pyplot.  If
583    # we are in the middle of testing, we need to switch the backend to
584    # 'Agg', otherwise attempts to generate plots on CI services without
585    # terminal windows will fail.
586    if in_testing_environment():
587        module.use('Agg')
588    import matplotlib.pyplot
589
590def _finalize_numpy(np, available):
591    if not available:
592        return
593    numeric_types.RegisterBooleanType(np.bool_)
594    for t in (np.int_, np.intc, np.intp,
595              np.int8, np.int16, np.int32, np.int64,
596              np.uint8, np.uint16, np.uint32, np.uint64):
597        numeric_types.RegisterIntegerType(t)
598        numeric_types.RegisterBooleanType(t)
599    for t in (np.float_, np.float16, np.float32, np.float64, np.ndarray):
600        numeric_types.RegisterNumericType(t)
601        numeric_types.RegisterBooleanType(t)
602
603
604yaml, yaml_available = attempt_import(
605    'yaml', callback=_finalize_yaml)
606pympler, pympler_available = attempt_import(
607    'pympler', callback=_finalize_pympler)
608numpy, numpy_available = attempt_import(
609    'numpy', callback=_finalize_numpy)
610scipy, scipy_available = attempt_import(
611    'scipy', callback=_finalize_scipy,
612    deferred_submodules=['stats', 'sparse', 'spatial', 'integrate'])
613networkx, networkx_available = attempt_import('networkx')
614pandas, pandas_available = attempt_import('pandas')
615dill, dill_available = attempt_import('dill')
616pyutilib, pyutilib_available = attempt_import('pyutilib')
617
618# Note that matplotlib.pyplot can generate a runtime error on OSX when
619# not installed as a Framework (as is the case in the CI systems)
620matplotlib, matplotlib_available = attempt_import(
621    'matplotlib',
622    callback=_finalize_matplotlib,
623    deferred_submodules=['pyplot', 'pylab'],
624    catch_exceptions=(ImportError, RuntimeError),
625)
626
627try:
628    import cPickle as pickle
629except ImportError:
630    import pickle
631
632declare_deferred_modules_as_importable(globals())
633