Aluode/PerceptionLabPortable
0
1"""2lazy_loader3===========4 5Makes it easy to load subpackages and functions on demand.6"""7 8import ast9import importlib10import importlib.util11import os12import sys13import threading14import types15import warnings16 17__version__ = "0.4"18__all__ = ["attach", "load", "attach_stub"]19 20 21threadlock = threading.Lock()22 23 24def attach(package_name, submodules=None, submod_attrs=None):25 """Attach lazily loaded submodules, functions, or other attributes.26 27 Typically, modules import submodules and attributes as follows::28 29 import mysubmodule30 import anothersubmodule31 32 from .foo import someattr33 34 The idea is to replace a package's `__getattr__`, `__dir__`, and35 `__all__`, such that all imports work exactly the way they would36 with normal imports, except that the import occurs upon first use.37 38 The typical way to call this function, replacing the above imports, is::39 40 __getattr__, __dir__, __all__ = lazy.attach(41 __name__,42 ['mysubmodule', 'anothersubmodule'],43 {'foo': ['someattr']}44 )45 46 This functionality requires Python 3.7 or higher.47 48 Parameters49 ----------50 package_name : str51 Typically use ``__name__``.52 submodules : set53 List of submodules to attach.54 submod_attrs : dict55 Dictionary of submodule -> list of attributes / functions.56 These attributes are imported as they are used.57 58 Returns59 -------60 __getattr__, __dir__, __all__61 62 """63 if submod_attrs is None:64 submod_attrs = {}65 66 if submodules is None:67 submodules = set()68 else:69 submodules = set(submodules)70 71 attr_to_modules = {72 attr: mod for mod, attrs in submod_attrs.items() for attr in attrs73 }74 75 __all__ = sorted(submodules | attr_to_modules.keys())76 77 def __getattr__(name):78 if name in submodules:79 return importlib.import_module(f"{package_name}.{name}")80 elif name in attr_to_modules:81 submod_path = f"{package_name}.{attr_to_modules[name]}"82 submod = importlib.import_module(submod_path)83 attr = getattr(submod, name)84 85 # If the attribute lives in a file (module) with the same86 # name as the attribute, ensure that the attribute and *not*87 # the module is accessible on the package.88 if name == attr_to_modules[name]:89 pkg = sys.modules[package_name]90 pkg.__dict__[name] = attr91 92 return attr93 else:94 raise AttributeError(f"No {package_name} attribute {name}")95 96 def __dir__():97 return __all__98 99 if os.environ.get("EAGER_IMPORT", ""):100 for attr in set(attr_to_modules.keys()) | submodules:101 __getattr__(attr)102 103 return __getattr__, __dir__, list(__all__)104 105 106class DelayedImportErrorModule(types.ModuleType):107 def __init__(self, frame_data, *args, message, **kwargs):108 self.__frame_data = frame_data109 self.__message = message110 super().__init__(*args, **kwargs)111 112 def __getattr__(self, x):113 if x in ("__class__", "__file__", "__frame_data", "__message"):114 super().__getattr__(x)115 else:116 fd = self.__frame_data117 raise ModuleNotFoundError(118 f"{self.__message}\n\n"119 "This error is lazily reported, having originally occured in\n"120 f' File {fd["filename"]}, line {fd["lineno"]}, in {fd["function"]}\n\n'121 f'----> {"".join(fd["code_context"] or "").strip()}'122 )123 124 125def load(fullname, *, require=None, error_on_import=False):126 """Return a lazily imported proxy for a module.127 128 We often see the following pattern::129 130 def myfunc():131 import numpy as np132 np.norm(...)133 ....134 135 Putting the import inside the function prevents, in this case,136 `numpy`, from being imported at function definition time.137 That saves time if `myfunc` ends up not being called.138 139 This `load` function returns a proxy module that, upon access, imports140 the actual module. So the idiom equivalent to the above example is::141 142 np = lazy.load("numpy")143 144 def myfunc():145 np.norm(...)146 ....147 148 The initial import time is fast because the actual import is delayed149 until the first attribute is requested. The overall import time may150 decrease as well for users that don't make use of large portions151 of your library.152 153 Warning154 -------155 While lazily loading *sub*packages technically works, it causes the156 package (that contains the subpackage) to be eagerly loaded even157 if the package is already lazily loaded.158 So, you probably shouldn't use subpackages with this `load` feature.159 Instead you should encourage the package maintainers to use the160 `lazy_loader.attach` to make their subpackages load lazily.161 162 Parameters163 ----------164 fullname : str165 The full name of the module or submodule to import. For example::166 167 sp = lazy.load('scipy') # import scipy as sp168 169 require : str170 A dependency requirement as defined in PEP-508. For example::171 172 "numpy >=1.24"173 174 If defined, the proxy module will raise an error if the installed175 version does not satisfy the requirement.176 177 error_on_import : bool178 Whether to postpone raising import errors until the module is accessed.179 If set to `True`, import errors are raised as soon as `load` is called.180 181 Returns182 -------183 pm : importlib.util._LazyModule184 Proxy module. Can be used like any regularly imported module.185 Actual loading of the module occurs upon first attribute request.186 187 """188 with threadlock:189 module = sys.modules.get(fullname)190 have_module = module is not None191 192 # Most common, short-circuit193 if have_module and require is None:194 return module195 196 if "." in fullname:197 msg = (198 "subpackages can technically be lazily loaded, but it causes the "199 "package to be eagerly loaded even if it is already lazily loaded."200 "So, you probably shouldn't use subpackages with this lazy feature."201 )202 warnings.warn(msg, RuntimeWarning)203 204 spec = None205 206 if not have_module:207 spec = importlib.util.find_spec(fullname)208 have_module = spec is not None209 210 if not have_module:211 not_found_message = f"No module named '{fullname}'"212 elif require is not None:213 try:214 have_module = _check_requirement(require)215 except ModuleNotFoundError as e:216 raise ValueError(217 f"Found module '{fullname}' but cannot test "218 "requirement '{require}'. "219 "Requirements must match distribution name, not module name."220 ) from e221 222 not_found_message = f"No distribution can be found matching '{require}'"223 224 if not have_module:225 if error_on_import:226 raise ModuleNotFoundError(not_found_message)227 import inspect228 229 try:230 parent = inspect.stack()[1]231 frame_data = {232 "filename": parent.filename,233 "lineno": parent.lineno,234 "function": parent.function,235 "code_context": parent.code_context,236 }237 return DelayedImportErrorModule(238 frame_data,239 "DelayedImportErrorModule",240 message=not_found_message,241 )242 finally:243 del parent244 245 if spec is not None:246 module = importlib.util.module_from_spec(spec)247 sys.modules[fullname] = module248 249 loader = importlib.util.LazyLoader(spec.loader)250 loader.exec_module(module)251 252 return module253 254 255def _check_requirement(require: str) -> bool:256 """Verify that a package requirement is satisfied257 258 If the package is required, a ``ModuleNotFoundError`` is raised259 by ``importlib.metadata``.260 261 Parameters262 ----------263 require : str264 A dependency requirement as defined in PEP-508265 266 Returns267 -------268 satisfied : bool269 True if the installed version of the dependency matches270 the specified version, False otherwise.271 """272 import packaging.requirements273 274 try:275 import importlib.metadata as importlib_metadata276 except ImportError: # PY37277 import importlib_metadata278 279 req = packaging.requirements.Requirement(require)280 return req.specifier.contains(281 importlib_metadata.version(req.name),282 prereleases=True,283 )284 285 286class _StubVisitor(ast.NodeVisitor):287 """AST visitor to parse a stub file for submodules and submod_attrs."""288 289 def __init__(self):290 self._submodules = set()291 self._submod_attrs = {}292 293 def visit_ImportFrom(self, node: ast.ImportFrom):294 if node.level != 1:295 raise ValueError(296 "Only within-module imports are supported (`from .* import`)"297 )298 if node.module:299 attrs: list = self._submod_attrs.setdefault(node.module, [])300 aliases = [alias.name for alias in node.names]301 if "*" in aliases:302 raise ValueError(303 "lazy stub loader does not support star import "304 f"`from {node.module} import *`"305 )306 attrs.extend(aliases)307 else:308 self._submodules.update(alias.name for alias in node.names)309 310 311def attach_stub(package_name: str, filename: str):312 """Attach lazily loaded submodules, functions from a type stub.313 314 This is a variant on ``attach`` that will parse a `.pyi` stub file to315 infer ``submodules`` and ``submod_attrs``. This allows static type checkers316 to find imports, while still providing lazy loading at runtime.317 318 Parameters319 ----------320 package_name : str321 Typically use ``__name__``.322 filename : str323 Path to `.py` file which has an adjacent `.pyi` file.324 Typically use ``__file__``.325 326 Returns327 -------328 __getattr__, __dir__, __all__329 The same output as ``attach``.330 331 Raises332 ------333 ValueError334 If a stub file is not found for `filename`, or if the stubfile is formmated335 incorrectly (e.g. if it contains an relative import from outside of the module)336 """337 stubfile = (338 filename if filename.endswith("i") else f"{os.path.splitext(filename)[0]}.pyi"339 )340 341 if not os.path.exists(stubfile):342 raise ValueError(f"Cannot load imports from non-existent stub {stubfile!r}")343 344 with open(stubfile) as f:345 stub_node = ast.parse(f.read())346 347 visitor = _StubVisitor()348 visitor.visit(stub_node)349 return attach(package_name, visitor._submodules, visitor._submod_attrs)350 