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