meerschaum.plugins
Expose plugin management APIs from the meerschaum.plugins module.
1#! /usr/bin/env python 2# -*- coding: utf-8 -*- 3# vim:fenc=utf-8 4 5""" 6Expose plugin management APIs from the `meerschaum.plugins` module. 7""" 8 9from __future__ import annotations 10 11import pathlib 12import functools 13from collections import defaultdict 14 15import meerschaum as mrsm 16from meerschaum.utils.typing import Callable, Any, Union, Optional, Dict, List, Tuple 17from meerschaum.utils.threading import RLock 18from meerschaum.core.Plugin import Plugin 19 20_api_plugins: Dict[str, List[Callable[['fastapi.App'], Any]]] = {} 21_plugins_import_errors: Dict[str, Exception] = {} 22_pre_sync_hooks: Dict[Union[str, None], List[Callable[[Any], Any]]] = {} 23_post_sync_hooks: Dict[Union[str, None], List[Callable[[Any], Any]]] = {} 24_actions_daemon_enabled: Dict[str, bool] = {} 25_locks = { 26 '_api_plugins': RLock(), 27 '_dash_plugins': RLock(), 28 '_pre_sync_hooks': RLock(), 29 '_post_sync_hooks': RLock(), 30 '_actions_daemon_enabled': RLock(), 31 '__path__': RLock(), 32 'sys.path': RLock(), 33 'internal_plugins': RLock(), 34 '_synced_symlinks': RLock(), 35 'PLUGINS_INTERNAL_LOCK_PATH': RLock(), 36} 37__all__ = ( 38 "Plugin", 39 "make_action", 40 "api_plugin", 41 "dash_plugin", 42 "web_page", 43 "import_plugins", 44 "from_plugin_import", 45 "invalidate_plugins_cache", 46 "reload_plugins", 47 "get_plugins", 48 "get_data_plugins", 49 "add_plugin_argument", 50 "pre_sync_hook", 51 "post_sync_hook", 52) 53__pdoc__ = { 54 'venvs': False, 55 'data': False, 56 'stack': False, 57 'plugins': False, 58} 59 60_FIRST_PARTY_PLUGIN_MODULES = { 61 'compose': 'meerschaum.compose', 62} 63 64 65def _import_first_party_plugin(plugin_import_name: str): 66 """Import a compatibility alias for a plugin which moved into Meerschaum core.""" 67 import importlib 68 69 plugin_import_parts = plugin_import_name.split('.') 70 module_root = _FIRST_PARTY_PLUGIN_MODULES.get(plugin_import_parts[0], None) 71 if module_root is None: 72 return None 73 return importlib.import_module('.'.join([module_root] + plugin_import_parts[1:])) 74 75 76def make_action( 77 function: Optional[Callable[[Any], Any]] = None, 78 shell: bool = False, 79 activate: bool = True, 80 deactivate: bool = True, 81 debug: bool = False, 82 daemon: bool = True, 83 skip_if_loaded: bool = True, 84 _plugin_name: Optional[str] = None, 85) -> Callable[[Any], Any]: 86 """ 87 Make a function a Meerschaum action. Useful for plugins that are adding multiple actions. 88 89 Parameters 90 ---------- 91 function: Callable[[Any], Any] 92 The function to become a Meerschaum action. Must accept all keyword arguments. 93 94 shell: bool, default False 95 Not used. 96 97 Returns 98 ------- 99 Another function (this is a decorator function). 100 101 Examples 102 -------- 103 >>> from meerschaum.plugins import make_action 104 >>> 105 >>> @make_action 106 ... def my_action(**kw): 107 ... print('foo') 108 ... return True, "Success" 109 >>> 110 """ 111 def _decorator(func: Callable[[Any], Any]) -> Callable[[Any], Any]: 112 from meerschaum.actions import actions, _custom_actions_plugins, _plugins_actions 113 if skip_if_loaded and func.__name__ in actions: 114 return func 115 116 plugin_name = _plugin_name or _get_parent_plugin(function=func) 117 plugin = Plugin(plugin_name) if plugin_name else None 118 119 if debug: 120 from meerschaum.utils.debug import dprint 121 dprint( 122 f"Adding action '{func.__name__}' from plugin " 123 f"'{plugin}'..." 124 ) 125 126 actions[func.__name__] = func 127 _custom_actions_plugins[func.__name__] = plugin_name 128 if plugin_name not in _plugins_actions: 129 _plugins_actions[plugin_name] = [] 130 _plugins_actions[plugin_name].append(func.__name__) 131 if not daemon: 132 _actions_daemon_enabled[func.__name__] = False 133 return func 134 135 if function is None: 136 return _decorator 137 return _decorator(function) 138 139 140def pre_sync_hook( 141 function: Callable[[Any], Any], 142) -> Callable[[Any], Any]: 143 """ 144 Register a function as a sync hook to be executed right before sync. 145 146 Parameters 147 ---------- 148 function: Callable[[Any], Any] 149 The function to execute right before a sync. 150 151 Returns 152 ------- 153 Another function (this is a decorator function). 154 155 Examples 156 -------- 157 >>> from meerschaum.plugins import pre_sync_hook 158 >>> 159 >>> @pre_sync_hook 160 ... def log_sync(pipe, **kwargs): 161 ... print(f"About to sync {pipe} with kwargs:\n{kwargs}.") 162 >>> 163 """ 164 with _locks['_pre_sync_hooks']: 165 plugin_name = _get_parent_plugin(function=function) 166 try: 167 if plugin_name not in _pre_sync_hooks: 168 _pre_sync_hooks[plugin_name] = [] 169 _pre_sync_hooks[plugin_name].append(function) 170 except Exception as e: 171 from meerschaum.utils.warnings import warn 172 warn(e) 173 return function 174 175 176def post_sync_hook( 177 function: Callable[[Any], Any], 178) -> Callable[[Any], Any]: 179 """ 180 Register a function as a sync hook to be executed upon completion of a sync. 181 182 Parameters 183 ---------- 184 function: Callable[[Any], Any] 185 The function to execute upon completion of a sync. 186 187 Returns 188 ------- 189 Another function (this is a decorator function). 190 191 Examples 192 -------- 193 >>> from meerschaum.plugins import post_sync_hook 194 >>> from meerschaum.utils.misc import interval_str 195 >>> from datetime import timedelta 196 >>> 197 >>> @post_sync_hook 198 ... def log_sync(pipe, success_tuple, duration=None, **kwargs): 199 ... duration_delta = timedelta(seconds=duration) 200 ... duration_text = interval_str(duration_delta) 201 ... print(f"It took {duration_text} to sync {pipe}.") 202 >>> 203 """ 204 with _locks['_post_sync_hooks']: 205 try: 206 plugin_name = _get_parent_plugin(function=function) 207 if plugin_name not in _post_sync_hooks: 208 _post_sync_hooks[plugin_name] = [] 209 _post_sync_hooks[plugin_name].append(function) 210 except Exception as e: 211 from meerschaum.utils.warnings import warn 212 warn(e) 213 return function 214 215 216_plugin_endpoints_to_pages = {} 217_plugins_web_pages = {} 218def web_page( 219 page: Union[str, None, Callable[[Any], Any]] = None, 220 login_required: bool = True, 221 skip_navbar: bool = False, 222 page_group: Optional[str] = None, 223 dark_theme: bool = True, 224 **kwargs 225) -> Any: 226 """ 227 Quickly add pages to the dash application. 228 229 Parameters 230 ---------- 231 dark_theme: bool, default True 232 If `True`, apply the Web Console's `dbc_dark` component theme to this page 233 (the default — most plugins want this). Set to `False` to opt out: the 234 `dbc_dark` class is removed from `<body>` while this page is active, so a 235 plugin's own styling applies without competing with the theme's overrides. 236 Note the global base Bootstrap theme (dark background) still applies, so an 237 opted-out page should paint its own background. 238 239 Examples 240 -------- 241 >>> import meerschaum as mrsm 242 >>> from meerschaum.plugins import web_page 243 >>> html = mrsm.attempt_import('dash.html') 244 >>> 245 >>> @web_page('foo/bar', login_required=False) 246 >>> def foo_bar(): 247 ... return html.Div([html.H1("Hello, World!")]) 248 >>> 249 """ 250 page_str = None 251 252 def _decorator(_func: Callable[[Any], Any]) -> Callable[[Any], Any]: 253 nonlocal page_str, page_group 254 255 @functools.wraps(_func) 256 def wrapper(*_args, **_kwargs): 257 return _func(*_args, **_kwargs) 258 259 if page_str is None: 260 page_str = _func.__name__ 261 262 page_str = page_str.lstrip('/').rstrip('/').strip() 263 if not page_str.startswith('dash'): 264 page_str = f'/dash/{page_str}' 265 page_key = ( 266 ' '.join( 267 [ 268 word.capitalize() 269 for word in ( 270 page_str.replace('/dash', '').lstrip('/').rstrip('/').strip() 271 .replace('-', ' ').replace('_', ' ').split(' ') 272 ) 273 ] 274 ) 275 ) 276 277 plugin_name = _get_parent_plugin(function=_func) 278 page_group = page_group or plugin_name 279 if page_group not in _plugin_endpoints_to_pages: 280 _plugin_endpoints_to_pages[page_group] = {} 281 _plugin_endpoints_to_pages[page_group][page_str] = { 282 'function': _func, 283 'login_required': login_required, 284 'skip_navbar': skip_navbar, 285 'page_key': page_key, 286 'dark_theme': dark_theme, 287 } 288 if plugin_name not in _plugins_web_pages: 289 _plugins_web_pages[plugin_name] = [] 290 _plugins_web_pages[plugin_name].append(_func) 291 return wrapper 292 293 if callable(page): 294 decorator_to_return = _decorator(page) 295 page_str = page.__name__ 296 else: 297 decorator_to_return = _decorator 298 page_str = page 299 300 return decorator_to_return 301 302 303_dash_plugins = {} 304def dash_plugin(function: Callable[[Any], Any]) -> Callable[[Any], Any]: 305 """ 306 Execute the function when starting the Dash application. 307 """ 308 with _locks['_dash_plugins']: 309 plugin_name = _get_parent_plugin(function=function) 310 try: 311 if plugin_name not in _dash_plugins: 312 _dash_plugins[plugin_name] = [] 313 _dash_plugins[plugin_name].append(function) 314 except Exception as e: 315 from meerschaum.utils.warnings import warn 316 warn(e) 317 return function 318 319 320def api_plugin(function: Callable[[Any], Any]) -> Callable[[Any], Any]: 321 """ 322 Execute the function when initializing the Meerschaum API module. 323 Useful for lazy-loading heavy plugins only when the API is started, 324 such as when editing the `meerschaum.api.app` FastAPI app. 325 326 The FastAPI app will be passed as the only parameter. 327 328 Examples 329 -------- 330 >>> from meerschaum.plugins import api_plugin 331 >>> 332 >>> @api_plugin 333 >>> def initialize_plugin(app): 334 ... @app.get('/my/new/path') 335 ... def new_path(): 336 ... return {'message': 'It works!'} 337 >>> 338 """ 339 with _locks['_api_plugins']: 340 try: 341 plugin_name = _get_parent_plugin(function=function) 342 if plugin_name not in _api_plugins: 343 _api_plugins[plugin_name] = [] 344 _api_plugins[plugin_name].append(function) 345 except Exception as e: 346 from meerschaum.utils.warnings import warn 347 warn(e) 348 return function 349 350 351_synced_symlinks: int = 0 352_injected_plugin_symlinks = defaultdict(lambda: 0) 353def sync_plugins_symlinks(debug: bool = False, warn: bool = True) -> None: 354 """ 355 Update the plugins' internal symlinks. 356 """ 357 from meerschaum.utils.warnings import error, warn as _warn, dprint 358 global _synced_symlinks 359 with _locks['_synced_symlinks']: 360 if _synced_symlinks > 1: 361 if debug: 362 dprint("Skip syncing symlinks...") 363 return 364 365 import os 366 import pathlib 367 from collections import defaultdict 368 from meerschaum.utils.misc import flatten_list, make_symlink, is_symlink 369 import meerschaum.config.paths as paths 370 from meerschaum.utils.locks import InterProcessLock 371 372 with InterProcessLock(paths.PLUGINS_INTERNAL_LOCK_PATH), _locks['internal_plugins']: 373 374 try: 375 from importlib.metadata import entry_points 376 except ImportError: 377 importlib_metadata = mrsm.attempt_import('importlib_metadata', lazy=False) 378 entry_points = importlib_metadata.entry_points 379 380 ### NOTE: Allow plugins to be installed via `pip`. 381 packaged_plugin_paths = [] 382 try: 383 discovered_packaged_plugins_eps = entry_points(group='meerschaum.plugins') 384 except TypeError: 385 discovered_packaged_plugins_eps = [] 386 387 for ep in discovered_packaged_plugins_eps: 388 module_name = ep.name 389 for package_file_path in ep.dist.files: 390 if package_file_path.suffix != '.py': 391 continue 392 if str(package_file_path) == f'{module_name}.py': 393 packaged_plugin_paths.append(package_file_path.locate()) 394 elif str(package_file_path) == f'{module_name}/__init__.py': 395 packaged_plugin_paths.append(package_file_path.locate().parent) 396 397 if is_symlink(paths.PLUGINS_RESOURCES_PATH) or not paths.PLUGINS_RESOURCES_PATH.exists(): 398 try: 399 paths.PLUGINS_RESOURCES_PATH.unlink() 400 except Exception: 401 pass 402 403 paths.PLUGINS_RESOURCES_PATH.mkdir(exist_ok=True) 404 405 existing_symlinked_paths = { 406 _existing_symlink: pathlib.Path(os.path.realpath(_existing_symlink)) 407 for item in os.listdir(paths.PLUGINS_RESOURCES_PATH) 408 if is_symlink(_existing_symlink := (paths.PLUGINS_RESOURCES_PATH / item)) 409 } 410 injected_symlinked_paths = { 411 _injected_symlink: pathlib.Path(os.path.realpath(_injected_symlink)) 412 for item in os.listdir(paths.PLUGINS_INJECTED_RESOURCES_PATH) 413 if is_symlink(_injected_symlink := (paths.PLUGINS_INJECTED_RESOURCES_PATH / item)) 414 } 415 plugins_to_be_symlinked = list(flatten_list( 416 [ 417 [ 418 pathlib.Path(os.path.realpath(plugins_path / item)) 419 for item in os.listdir(plugins_path) 420 if ( 421 not item.startswith('.') 422 ) and (item not in ('__pycache__', '__init__.py')) 423 ] 424 for plugins_path in paths.PLUGINS_DIR_PATHS 425 if plugins_path.exists() 426 ] 427 )) 428 plugins_to_be_symlinked.extend(packaged_plugin_paths) 429 430 ### Check for duplicates. 431 seen_plugins = defaultdict(lambda: 0) 432 for plugin_path in plugins_to_be_symlinked: 433 plugin_name = plugin_path.stem 434 seen_plugins[plugin_name] += 1 435 for plugin_name, plugin_count in seen_plugins.items(): 436 if plugin_count > 1: 437 if warn: 438 _warn(f"Found duplicate plugins named '{plugin_name}'.") 439 440 for plugin_symlink_path, real_path in existing_symlinked_paths.items(): 441 442 ### Remove invalid symlinks. 443 if real_path not in plugins_to_be_symlinked: 444 if _injected_plugin_symlinks[plugin_symlink_path] > 1: 445 continue 446 if plugin_symlink_path in injected_symlinked_paths: 447 continue 448 if real_path in injected_symlinked_paths.values(): 449 continue 450 ### Only reap a symlink whose target NO LONGER EXISTS (a genuinely stale 451 ### plugin). A symlink whose target still exists is a valid plugin from 452 ### another configured plugins directory and must not be removed here: 453 ### `PLUGINS_DIR_PATHS` is process-global and can transiently fall back to 454 ### the host default when `MRSM_PLUGINS_DIR` is momentarily absent from the 455 ### environment (e.g. a daemon thread or a `replace_env` context). Removing 456 ### valid cross-dir symlinks in that window deletes a project's plugin 457 ### links from the shared per-root `.internal/plugins`, so a 458 ### `plugin:<name>`-backed background job then fails to import its plugin. 459 try: 460 if real_path.exists(): 461 continue 462 except Exception: 463 pass 464 try: 465 plugin_symlink_path.unlink() 466 except Exception: 467 pass 468 469 ### Remove valid plugins from the to-be-symlinked list. 470 else: 471 plugins_to_be_symlinked.remove(real_path) 472 473 for plugin_path in plugins_to_be_symlinked: 474 plugin_symlink_path = paths.PLUGINS_RESOURCES_PATH / plugin_path.name 475 try: 476 ### There might be duplicate folders (e.g. __pycache__). 477 if ( 478 plugin_symlink_path.exists() 479 and 480 plugin_symlink_path.is_dir() 481 and 482 not is_symlink(plugin_symlink_path) 483 ): 484 continue 485 success, msg = make_symlink(plugin_path, plugin_symlink_path) 486 except Exception as e: 487 success, msg = False, str(e) 488 if not success: 489 if warn: 490 _warn( 491 f"Failed to create symlink {plugin_symlink_path} " 492 + f"to {plugin_path}:\n {msg}" 493 ) 494 495 with _locks['PLUGINS_INTERNAL_LOCK_PATH']: 496 try: 497 if not paths.PLUGINS_INIT_PATH.exists(): 498 paths.PLUGINS_INIT_PATH.touch() 499 except Exception as e: 500 error(f"Failed to create the file '{paths.PLUGINS_INIT_PATH}':\n{e}") 501 502 with _locks['__path__']: 503 if str(paths.PLUGINS_RESOURCES_PATH.parent) not in __path__: 504 __path__.append(str(paths.PLUGINS_RESOURCES_PATH.parent)) 505 506 with _locks['_synced_symlinks']: 507 _synced_symlinks += 1 508 509 510def import_plugins( 511 *plugins_to_import: Union[str, List[str], None], 512 warn: bool = True, 513) -> Union[ 514 'ModuleType', Tuple['ModuleType', None] 515]: 516 """ 517 Import the Meerschaum plugins directory. 518 519 Parameters 520 ---------- 521 plugins_to_import: Union[str, List[str], None] 522 If provided, only import the specified plugins. 523 Otherwise import the entire plugins module. May be a string, list, or `None`. 524 Defaults to `None`. 525 526 Returns 527 ------- 528 A module of list of modules, depening on the number of plugins provided. 529 530 """ 531 import sys 532 import importlib 533 import meerschaum.config.paths as paths 534 from meerschaum.utils.misc import flatten_list 535 from meerschaum.utils.venv import is_venv_active, activate_venv, deactivate_venv, Venv 536 from meerschaum.utils.warnings import warn as _warn 537 plugins_to_import = list(plugins_to_import) 538 prepended_sys_path = False 539 with _locks['sys.path']: 540 541 ### Since plugins may depend on other plugins, 542 ### we need to activate the virtual environments for library plugins. 543 ### This logic exists in `Plugin.activate_venv()`, 544 ### but that code requires the plugin's module to already be imported. 545 ### It's not a guarantee of correct activation order, 546 ### e.g. if a library plugin pins a specific package and another 547 plugins_names = get_plugins_names() 548 already_active_venvs = { 549 plugin_name 550 for plugin_name in plugins_names 551 if is_venv_active(plugin_name) 552 } 553 554 if not sys.path or sys.path[0] != str(paths.PLUGINS_RESOURCES_PATH.parent): 555 prepended_sys_path = True 556 sys.path.insert(0, str(paths.PLUGINS_RESOURCES_PATH.parent)) 557 558 if not plugins_to_import: 559 for plugin_name in plugins_names: 560 activate_venv(plugin_name) 561 try: 562 imported_plugins = importlib.import_module(paths.PLUGINS_RESOURCES_PATH.stem) 563 except ImportError as e: 564 _warn(f"Failed to import the plugins module:\n {e}") 565 import traceback 566 traceback.print_exc() 567 imported_plugins = None 568 for plugin_name in plugins_names: 569 if plugin_name in already_active_venvs: 570 continue 571 deactivate_venv(plugin_name) 572 573 else: 574 imported_plugins = [] 575 for plugin_name in flatten_list(plugins_to_import): 576 plugin = Plugin(plugin_name) 577 try: 578 with Venv(plugin, init_if_not_exists=False): 579 imported_plugins.append( 580 importlib.import_module( 581 f'{paths.PLUGINS_RESOURCES_PATH.stem}.{plugin_name}' 582 ) 583 ) 584 _ = _plugins_import_errors.pop(plugin_name, None) 585 except Exception as e: 586 _plugins_import_errors[plugin_name] = e 587 _warn( 588 f"Failed to import plugin '{plugin_name}':\n " 589 + f"{e}\n\nHere's a stacktrace:", 590 stack = False, 591 ) 592 from meerschaum.utils.formatting import get_console 593 get_console().print_exception( 594 suppress = [ 595 'meerschaum/plugins/__init__.py', 596 importlib, 597 importlib._bootstrap, 598 ] 599 ) 600 imported_plugins.append(None) 601 602 if imported_plugins is None and warn: 603 _warn("Failed to import plugins.", stacklevel=3) 604 605 if prepended_sys_path and str(paths.PLUGINS_RESOURCES_PATH.parent) in sys.path: 606 sys.path.remove(str(paths.PLUGINS_RESOURCES_PATH.parent)) 607 608 if isinstance(imported_plugins, list): 609 return (imported_plugins[0] if len(imported_plugins) == 1 else tuple(imported_plugins)) 610 return imported_plugins 611 612 613def from_plugin_import(plugin_import_name: str, *attrs: str) -> Any: 614 """ 615 Emulate the `from module import x` behavior. 616 617 Parameters 618 ---------- 619 plugin_import_name: str 620 The import name of the plugin's module. 621 Separate submodules with '.' (e.g. 'compose.utils.pipes') 622 623 attrs: str 624 Names of the attributes to return. 625 626 Returns 627 ------- 628 Objects from a plugin's submodule. 629 If multiple objects are provided, return a tuple. 630 631 Examples 632 -------- 633 >>> init = from_plugin_import('compose.utils', 'init') 634 >>> with mrsm.Venv('compose'): 635 ... cf = init() 636 >>> build_parent_pipe, get_defined_pipes = from_plugin_import( 637 ... 'compose.utils.pipes', 638 ... 'build_parent_pipe', 639 ... 'get_defined_pipes', 640 ... ) 641 >>> parent_pipe = build_parent_pipe(cf) 642 >>> defined_pipes = get_defined_pipes(cf) 643 """ 644 import importlib 645 import meerschaum.config.paths as paths 646 from meerschaum.utils.warnings import warn as _warn 647 if plugin_import_name.startswith('plugins.'): 648 plugin_import_name = plugin_import_name[len('plugins.'):] 649 plugin_import_parts = plugin_import_name.split('.') 650 plugin_root_name = plugin_import_parts[0] 651 652 submodule_import_name = '.'.join( 653 [paths.PLUGINS_RESOURCES_PATH.stem] 654 + plugin_import_parts 655 ) 656 if len(attrs) == 0: 657 raise ValueError(f"Provide which attributes to return from '{submodule_import_name}'.") 658 659 first_party_submodule = _import_first_party_plugin(plugin_import_name) 660 if first_party_submodule is not None: 661 attrs_to_return = [getattr(first_party_submodule, attr) for attr in attrs] 662 return attrs_to_return[0] if len(attrs_to_return) == 1 else tuple(attrs_to_return) 663 664 plugin = mrsm.Plugin(plugin_root_name) 665 attrs_to_return = [] 666 with mrsm.Venv(plugin): 667 if plugin.module is None: 668 raise ImportError(f"Unable to import plugin '{plugin}'.") 669 670 try: 671 submodule = importlib.import_module(submodule_import_name) 672 except ImportError as e: 673 _warn( 674 f"Failed to import plugin '{submodule_import_name}':\n " 675 + f"{e}\n\nHere's a stacktrace:", 676 stack=False, 677 ) 678 from meerschaum.utils.formatting import get_console 679 get_console().print_exception( 680 suppress=[ 681 'meerschaum/plugins/__init__.py', 682 importlib, 683 importlib._bootstrap, 684 ] 685 ) 686 return None 687 688 for attr in attrs: 689 try: 690 attrs_to_return.append(getattr(submodule, attr)) 691 except Exception: 692 _warn(f"Failed to access '{attr}' from '{submodule_import_name}'.") 693 attrs_to_return.append(None) 694 695 if len(attrs) == 1: 696 return attrs_to_return[0] 697 698 return tuple(attrs_to_return) 699 700 701_loaded_plugins: bool = False 702def load_plugins( 703 skip_if_loaded: bool = True, 704 shell: bool = False, 705 debug: bool = False, 706) -> None: 707 """ 708 Import Meerschaum plugins and update the actions dictionary. 709 """ 710 global _loaded_plugins 711 from meerschaum.utils.warnings import dprint 712 713 if skip_if_loaded and _loaded_plugins: 714 if debug: 715 dprint("Skip loading plugins...") 716 return 717 718 from inspect import isfunction, getmembers 719 import meerschaum.config.paths as paths 720 from meerschaum.actions import __all__ as _all, modules 721 from meerschaum.utils.packages import get_modules_from_package 722 723 _plugins_names, plugins_modules = get_modules_from_package( 724 import_plugins(), 725 names = True, 726 recursive = True, 727 modules_venvs = True 728 ) 729 730 ### I'm appending here to keep from redefining the modules list. 731 new_modules = ( 732 [ 733 mod 734 for mod in modules 735 if not mod.__name__.startswith(paths.PLUGINS_RESOURCES_PATH.stem + '.') 736 ] 737 + plugins_modules 738 ) 739 n_mods = len(modules) 740 for mod in new_modules: 741 modules.append(mod) 742 for i in range(n_mods): 743 modules.pop(0) 744 745 for module in plugins_modules: 746 for name, func in getmembers(module): 747 if not isfunction(func): 748 continue 749 if name == module.__name__.split('.')[-1]: 750 make_action( 751 func, 752 **{'shell': shell, 'debug': debug}, 753 _plugin_name=_get_parent_plugin(function=func), 754 skip_if_loaded=True, 755 ) 756 757 _loaded_plugins = True 758 759 760def unload_custom_actions(plugins: Optional[List[str]] = None, debug: bool = False) -> None: 761 """ 762 Unload the custom actions added by plugins. 763 """ 764 from meerschaum.actions import ( 765 actions, 766 _custom_actions_plugins, 767 _plugins_actions, 768 ) 769 from meerschaum._internal.entry import _shell 770 import meerschaum._internal.shell as shell_pkg 771 772 plugins = plugins if plugins is not None else list(_plugins_actions) 773 774 for plugin in plugins: 775 action_names = _plugins_actions.get(plugin, []) 776 actions_to_remove = { 777 action_name: actions.get(action_name, None) 778 for action_name in action_names 779 } 780 for action_name in action_names: 781 _ = actions.pop(action_name, None) 782 _ = _custom_actions_plugins.pop(action_name, None) 783 _ = _actions_daemon_enabled.pop(action_name, None) 784 785 _ = _plugins_actions.pop(plugin, None) 786 shell_pkg._remove_shell_actions( 787 _shell=_shell, 788 actions=actions_to_remove, 789 ) 790 791 792def unload_plugins( 793 plugins: Optional[List[str]] = None, 794 remove_symlinks: bool = True, 795 debug: bool = False, 796) -> None: 797 """ 798 Unload the specified plugins from memory. 799 """ 800 global _loaded_plugins, _synced_symlinks 801 import os 802 import sys 803 import pathlib 804 import meerschaum.config.paths as paths 805 from meerschaum.connectors import unload_plugin_connectors 806 if debug: 807 from meerschaum.utils.warnings import dprint 808 809 _loaded_plugins = False 810 _synced_symlinks = 0 811 812 all_plugins = get_plugins_names() 813 plugins = plugins if plugins is not None else all_plugins 814 if debug: 815 dprint(f"Unloading plugins: {plugins}") 816 817 unload_custom_actions(plugins, debug=debug) 818 unload_plugin_connectors(plugins, debug=debug) 819 820 module_prefix = f"{paths.PLUGINS_RESOURCES_PATH.stem}." 821 loaded_modules = [mod_name for mod_name in sys.modules if mod_name.startswith(module_prefix)] 822 823 root_plugins_mod = ( 824 sys.modules.get(paths.PLUGINS_RESOURCES_PATH.stem, None) 825 if sorted(plugins) != sorted(all_plugins) 826 else sys.modules.pop(paths.PLUGINS_RESOURCES_PATH.stem, None) 827 ) 828 829 for plugin_name in plugins: 830 for mod_name in loaded_modules: 831 if ( 832 mod_name[len(module_prefix):].startswith(plugin_name + '.') 833 or mod_name[len(module_prefix):] == plugin_name 834 ): 835 _ = sys.modules.pop(mod_name, None) 836 837 if root_plugins_mod is not None and plugin_name in root_plugins_mod.__dict__: 838 try: 839 delattr(root_plugins_mod, plugin_name) 840 except Exception: 841 pass 842 843 ### Unload sync hooks. 844 _ = _pre_sync_hooks.pop(plugin_name, None) 845 _ = _post_sync_hooks.pop(plugin_name, None) 846 _ = _plugins_import_errors.pop(plugin_name, None) 847 848 ### Unload API endpoints and pages. 849 _ = _dash_plugins.pop(plugin_name, None) 850 _ = _api_plugins.pop(plugin_name, None) 851 web_page_funcs = _plugins_web_pages.pop(plugin_name, None) or [] 852 page_groups_to_pop = [] 853 for page_group, page_functions in _plugin_endpoints_to_pages.items(): 854 page_functions_to_pop = [ 855 page_str 856 for page_str, page_payload in page_functions.items() 857 if page_payload.get('function', None) in web_page_funcs 858 ] 859 for page_str in page_functions_to_pop: 860 page_functions.pop(page_str, None) 861 if not page_functions: 862 page_groups_to_pop.append(page_group) 863 864 for page_group in page_groups_to_pop: 865 _plugin_endpoints_to_pages.pop(page_group, None) 866 867 ### Remove all but injected symlinks. 868 if remove_symlinks: 869 dir_symlink_path = paths.PLUGINS_RESOURCES_PATH / plugin_name 870 dir_symlink_injected_path = paths.PLUGINS_INJECTED_RESOURCES_PATH / plugin_name 871 file_symlink_path = paths.PLUGINS_RESOURCES_PATH / f"{plugin_name}.py" 872 file_symlink_injected_path = paths.PLUGINS_INJECTED_RESOURCES_PATH / f"{plugin_name}.py" 873 874 ### Only remove a symlink whose target NO LONGER EXISTS (a genuinely stale 875 ### plugin). The per-root `.internal/plugins` directory is SHARED by every 876 ### process operating on that root — including a `plugin:<name>`-backed 877 ### background job that may have been started moments earlier (e.g. by 878 ### `mrsm compose start jobs`, which loads project plugins, starts the job 879 ### daemon, then unloads). Removing a still-valid plugin symlink here deletes 880 ### it out from under that daemon, so its plugin import fails with 881 ### "Plugin '<name>' cannot be found". In-memory unloading (popping 882 ### `sys.modules` above) is process-local and is all that unloading needs; 883 ### the symlink lifecycle is owned by `sync_plugins_symlinks`. 884 def _target_exists(symlink_path): 885 try: 886 return pathlib.Path(os.path.realpath(symlink_path)).exists() 887 except Exception: 888 return False 889 890 try: 891 if ( 892 dir_symlink_path.exists() 893 and not dir_symlink_injected_path.exists() 894 and not _target_exists(dir_symlink_path) 895 ): 896 dir_symlink_path.unlink() 897 except Exception: 898 pass 899 900 try: 901 if ( 902 file_symlink_path.exists() 903 and not file_symlink_injected_path.exists() 904 and not _target_exists(file_symlink_path) 905 ): 906 file_symlink_path.unlink() 907 except Exception: 908 pass 909 910 911def invalidate_plugins_cache() -> None: 912 """ 913 Pop the cached `plugins` package and its submodules from `sys.modules` 914 and reset the loaded-plugins state. 915 916 The `plugins` package caches its `__path__` (the resolved 917 `PLUGINS_RESOURCES_PATH`) at import time. When the active root or 918 plugins-dir scope changes in-process (e.g. via 919 `meerschaum.config.environment.replace_env`), that stale `__path__` 920 makes subsequent plugin imports re-discover plugins under the previous 921 scope's `.internal/plugins` directory. Call this whenever the plugins 922 scope changes so the next import rebuilds against the current 923 `PLUGINS_RESOURCES_PATH`. 924 """ 925 global _loaded_plugins, _synced_symlinks 926 import sys 927 import meerschaum.config.paths as paths 928 929 plugins_stem = paths.PLUGINS_RESOURCES_PATH.stem 930 module_prefix = plugins_stem + '.' 931 for mod_name in [ 932 mod_name 933 for mod_name in sys.modules 934 if mod_name == plugins_stem or mod_name.startswith(module_prefix) 935 ]: 936 _ = sys.modules.pop(mod_name, None) 937 938 _loaded_plugins = False 939 ### Also reset the symlinks counter — it's per-scope state, and leaving it 940 ### >1 makes `sync_plugins_symlinks` skip creating the new scope's 941 ### `.internal/plugins` (so `plugins` imports as an empty namespace package). 942 _synced_symlinks = 0 943 944 945def reload_plugins(plugins: Optional[List[str]] = None, debug: bool = False) -> None: 946 """ 947 Reload plugins back into memory. 948 949 Parameters 950 ---------- 951 plugins: Optional[List[str]], default None 952 The plugins to reload. `None` will reload all plugins. 953 954 """ 955 global _synced_symlinks 956 unload_plugins(plugins, debug=debug) 957 _synced_symlinks = 0 958 sync_plugins_symlinks(debug=debug) 959 load_plugins(skip_if_loaded=False, debug=debug) 960 961 962def get_plugins(*to_load, try_import: bool = True) -> Union[Tuple[Plugin], Plugin]: 963 """ 964 Return a list of `Plugin` objects. 965 966 Parameters 967 ---------- 968 to_load: 969 If specified, only load specific plugins. 970 Otherwise return all plugins. 971 972 try_import: bool, default True 973 If `True`, allow for plugins to be imported. 974 """ 975 import meerschaum.config.paths as paths 976 import os 977 sync_plugins_symlinks() 978 _plugins = [ 979 Plugin(name) 980 for name in ( 981 to_load or [ 982 ( 983 name if (paths.PLUGINS_RESOURCES_PATH / name).is_dir() 984 else name[:-3] 985 ) 986 for name in os.listdir(paths.PLUGINS_RESOURCES_PATH) 987 if name != '__init__.py' 988 ] 989 ) 990 ] 991 plugins = tuple(plugin for plugin in _plugins if plugin.is_installed(try_import=try_import)) 992 if len(to_load) == 1: 993 if len(plugins) == 0: 994 raise ValueError(f"Plugin '{to_load[0]}' is not installed.") 995 return plugins[0] 996 return plugins 997 998 999def get_plugins_names(*to_load, **kw) -> List[str]: 1000 """ 1001 Return a list of installed plugins. 1002 """ 1003 return [plugin.name for plugin in get_plugins(*to_load, **kw)] 1004 1005 1006def get_plugins_modules(*to_load, **kw) -> List['ModuleType']: 1007 """ 1008 Return a list of modules for the installed plugins, or `None` if things break. 1009 """ 1010 return [plugin.module for plugin in get_plugins(*to_load, **kw)] 1011 1012 1013_data_plugins_cache: Dict[Tuple[str, ...], List[Plugin]] = {} 1014def get_data_plugins() -> List[Plugin]: 1015 """ 1016 Only return the modules of plugins with either `fetch()` or `sync()` functions. 1017 """ 1018 import inspect 1019 plugins = get_plugins() 1020 1021 ### ponytail: cache on the installed plugins' names. 1022 ### Accessing `plugin.module` re-enters `import_plugins()` (venv activation + 1023 ### a nested `get_plugins()`) for every plugin, so an uncached call costs 1024 ### hundreds of milliseconds. The shell completer calls this on every keystroke 1025 ### (via `get_connector_labels()`), which is what made typing `-c` laggy. 1026 ### Installing or removing a plugin changes the key, so the cache self-invalidates; 1027 ### editing a plugin in place requires a restart to pick up a new `fetch()`/`sync()`. 1028 cache_key = tuple(plugin.name for plugin in plugins) 1029 if cache_key in _data_plugins_cache: 1030 return _data_plugins_cache[cache_key] 1031 1032 data_names = {'sync', 'fetch'} 1033 data_plugins = [] 1034 for plugin in plugins: 1035 for name, ob in inspect.getmembers(plugin.module): 1036 if not inspect.isfunction(ob): 1037 continue 1038 if name not in data_names: 1039 continue 1040 data_plugins.append(plugin) 1041 1042 _data_plugins_cache[cache_key] = data_plugins 1043 return data_plugins 1044 1045 1046def add_plugin_argument(*args, **kwargs) -> None: 1047 """ 1048 Add argparse arguments under the 'Plugins options' group. 1049 Takes the same parameters as the regular argparse `add_argument()` function. 1050 1051 Examples 1052 -------- 1053 >>> add_plugin_argument('--foo', type=int, help="This is my help text!") 1054 >>> 1055 """ 1056 from meerschaum._internal.arguments._parser import groups, _seen_plugin_args, parser 1057 from meerschaum.utils.warnings import warn 1058 _parent_plugin_name = _get_parent_plugin() 1059 title = f"Plugin '{_parent_plugin_name}' options" if _parent_plugin_name else 'Custom options' 1060 group_key = 'plugin_' + (_parent_plugin_name or '') 1061 if group_key not in groups: 1062 groups[group_key] = parser.add_argument_group( 1063 title = title, 1064 ) 1065 _seen_plugin_args[group_key] = set() 1066 try: 1067 if str(args) not in _seen_plugin_args[group_key]: 1068 groups[group_key].add_argument(*args, **kwargs) 1069 _seen_plugin_args[group_key].add(str(args)) 1070 except Exception as e: 1071 warn(e) 1072 1073 1074def inject_plugin_path( 1075 plugin_path: pathlib.Path, 1076 plugins_resources_path: Optional[pathlib.Path] = None) -> None: 1077 """ 1078 Inject a plugin as a symlink into the internal `plugins` directory. 1079 1080 Parameters 1081 ---------- 1082 plugin_path: pathlib.Path 1083 The path to the plugin's source module. 1084 """ 1085 import meerschaum.config.paths as paths 1086 from meerschaum.utils.misc import make_symlink 1087 if plugins_resources_path is None: 1088 plugins_resources_path = paths.PLUGINS_RESOURCES_PATH 1089 plugins_injected_resources_path = paths.PLUGINS_INJECTED_RESOURCES_PATH 1090 else: 1091 plugins_injected_resources_path = plugins_resources_path / '.injected' 1092 1093 if plugin_path.is_dir(): 1094 plugin_name = plugin_path.name 1095 dest_path = plugins_resources_path / plugin_name 1096 injected_path = plugins_injected_resources_path / plugin_name 1097 elif plugin_path.name == '__init__.py': 1098 plugin_name = plugin_path.parent.name 1099 dest_path = plugins_resources_path / plugin_name 1100 injected_path = plugins_injected_resources_path / plugin_name 1101 elif plugin_path.name.endswith('.py'): 1102 plugin_name = plugin_path.name[:(-1 * len('.py'))] 1103 dest_path = plugins_resources_path / plugin_path.name 1104 injected_path = plugins_injected_resources_path / plugin_path.name 1105 else: 1106 raise ValueError(f"Cannot deduce plugin name from path '{plugin_path}'.") 1107 1108 _injected_plugin_symlinks[dest_path] += 1 1109 make_symlink(plugin_path, dest_path) 1110 make_symlink(plugin_path, injected_path) 1111 1112 1113def _get_parent_plugin( 1114 stacklevel: Union[int, Tuple[int, ...]] = (1, 2, 3, 4), 1115 function: Optional[Callable[[Any], Any]] = None, 1116) -> Union[str, None]: 1117 """If this function is called from outside a Meerschaum plugin, it will return None.""" 1118 import inspect 1119 module_name = getattr(function, '__module__', '') 1120 if module_name.startswith('plugins.'): 1121 return module_name.split('.')[1] 1122 if not isinstance(stacklevel, tuple): 1123 stacklevel = (stacklevel,) 1124 1125 for _level in stacklevel: 1126 try: 1127 parent_globals = inspect.stack()[_level][0].f_globals 1128 global_name = parent_globals.get('__name__', '') 1129 if global_name.startswith('meerschaum.'): 1130 continue 1131 plugin_name = global_name.replace('plugins.', '').split('.')[0] 1132 if plugin_name.startswith('_') or plugin_name == 'importlib': 1133 continue 1134 return plugin_name 1135 except Exception: 1136 continue 1137 1138 return None
50class Plugin: 51 """Handle packaging of Meerschaum plugins.""" 52 53 def __init__( 54 self, 55 name: str, 56 version: Optional[str] = None, 57 user_id: Optional[int] = None, 58 required: Optional[List[str]] = None, 59 attributes: Optional[Dict[str, Any]] = None, 60 archive_path: Optional[pathlib.Path] = None, 61 venv_path: Optional[pathlib.Path] = None, 62 repo_connector: Optional['mrsm.connectors.api.APIConnector'] = None, 63 repo: Union['mrsm.connectors.api.APIConnector', str, None] = None, 64 ): 65 import meerschaum.config.paths as paths 66 from meerschaum._internal.static import STATIC_CONFIG 67 sep = STATIC_CONFIG['plugins']['repo_separator'] 68 _repo = None 69 if sep in name: 70 try: 71 name, _repo = name.split(sep) 72 except Exception as e: 73 error(f"Invalid plugin name: '{name}'") 74 self._repo_in_name = _repo 75 76 if attributes is None: 77 attributes = {} 78 self.name = name 79 self.attributes = attributes 80 self.user_id = user_id 81 self._version = version 82 if required: 83 self._required = required 84 self.archive_path = ( 85 archive_path if archive_path is not None 86 else paths.PLUGINS_ARCHIVES_RESOURCES_PATH / f"{self.name}.tar.gz" 87 ) 88 self.venv_path = ( 89 venv_path if venv_path is not None 90 else paths.VIRTENV_RESOURCES_PATH / self.name 91 ) 92 self._repo_connector = repo_connector 93 self._repo_keys = repo 94 95 96 @property 97 def repo_connector(self): 98 """ 99 Return the repository connector for this plugin. 100 NOTE: This imports the `connectors` module, which imports certain plugin modules. 101 """ 102 if self._repo_connector is None: 103 from meerschaum.connectors.parse import parse_repo_keys 104 105 repo_keys = self._repo_keys or self._repo_in_name 106 if self._repo_in_name and self._repo_keys and self._repo_keys != self._repo_in_name: 107 error( 108 f"Received inconsistent repos: '{self._repo_in_name}' and '{self._repo_keys}'." 109 ) 110 repo_connector = parse_repo_keys(repo_keys) 111 self._repo_connector = repo_connector 112 return self._repo_connector 113 114 115 @property 116 def version(self): 117 """ 118 Return the plugin's module version is defined (`__version__`) if it's defined. 119 """ 120 if self._version is None: 121 try: 122 self._version = self.module.__version__ 123 except Exception as e: 124 self._version = None 125 return self._version 126 127 128 @property 129 def module(self): 130 """ 131 Return the Python module of the underlying plugin, 132 or `None` if the plugin is not installed or its import failed 133 (check `Plugin.import_error` for the swallowed exception). 134 """ 135 if '_module' not in self.__dict__ or self.__dict__.get('_module', None) is None: 136 if self.__file__ is None: 137 return None 138 139 from meerschaum.plugins import import_plugins 140 self._module = import_plugins(str(self), warn=False) 141 142 return self._module 143 144 @property 145 def import_error(self) -> Union[Exception, None]: 146 """ 147 Return the exception raised by this plugin's most recent failed import 148 (i.e. when `Plugin.module` is `None` even though the plugin is installed), 149 otherwise `None`. 150 """ 151 from meerschaum.plugins import _plugins_import_errors 152 return _plugins_import_errors.get(self.name, None) 153 154 155 @property 156 def __file__(self) -> Union[str, None]: 157 """ 158 Return the file path (str) of the plugin if it exists, otherwise `None`. 159 """ 160 if self.__dict__.get('_module', None) is not None: 161 return self.module.__file__ 162 163 import meerschaum.config.paths as paths 164 165 potential_dir = paths.PLUGINS_RESOURCES_PATH / self.name 166 if ( 167 potential_dir.exists() 168 and potential_dir.is_dir() 169 and (potential_dir / '__init__.py').exists() 170 ): 171 return str((potential_dir / '__init__.py').as_posix()) 172 173 potential_file = paths.PLUGINS_RESOURCES_PATH / (self.name + '.py') 174 if potential_file.exists() and not potential_file.is_dir(): 175 return str(potential_file.as_posix()) 176 177 return None 178 179 180 @property 181 def requirements_file_path(self) -> Union[pathlib.Path, None]: 182 """ 183 If a file named `requirements.txt` exists, return its path. 184 """ 185 if self.__file__ is None: 186 return None 187 path = pathlib.Path(self.__file__).parent / 'requirements.txt' 188 if not path.exists(): 189 return None 190 return path 191 192 193 def is_installed(self, **kw) -> bool: 194 """ 195 Check whether a plugin is correctly installed. 196 197 Returns 198 ------- 199 A `bool` indicating whether a plugin exists and is successfully imported. 200 """ 201 return self.__file__ is not None 202 203 204 def make_tar(self, debug: bool = False) -> pathlib.Path: 205 """ 206 Compress the plugin's source files into a `.tar.gz` archive and return the archive's path. 207 208 Parameters 209 ---------- 210 debug: bool, default False 211 Verbosity toggle. 212 213 Returns 214 ------- 215 A `pathlib.Path` to the archive file's path. 216 217 """ 218 import tarfile, pathlib, subprocess, fnmatch 219 from meerschaum.utils.debug import dprint 220 from meerschaum.utils.packages import attempt_import 221 pathspec = attempt_import('pathspec', debug=debug) 222 223 if not self.__file__: 224 from meerschaum.utils.warnings import error 225 error(f"Could not find file for plugin '{self}'.") 226 if '__init__.py' in self.__file__ or os.path.isdir(self.__file__): 227 path = self.__file__.replace('__init__.py', '') 228 is_dir = True 229 else: 230 path = self.__file__ 231 is_dir = False 232 233 old_cwd = os.getcwd() 234 real_parent_path = pathlib.Path(os.path.realpath(path)).parent 235 os.chdir(real_parent_path) 236 237 default_patterns_to_ignore = [ 238 '.pyc', 239 '__pycache__/', 240 'eggs/', 241 '__pypackages__/', 242 '.git', 243 ] 244 245 def parse_gitignore() -> 'Set[str]': 246 gitignore_path = pathlib.Path(path) / '.gitignore' 247 if not gitignore_path.exists(): 248 return set(default_patterns_to_ignore) 249 with open(gitignore_path, 'r', encoding='utf-8') as f: 250 gitignore_text = f.read() 251 return set(pathspec.PathSpec.from_lines( 252 pathspec.patterns.GitWildMatchPattern, 253 default_patterns_to_ignore + gitignore_text.splitlines() 254 ).match_tree(path)) 255 256 patterns_to_ignore = parse_gitignore() if is_dir else set() 257 258 if debug: 259 dprint(f"Patterns to ignore:\n{patterns_to_ignore}") 260 261 with tarfile.open(self.archive_path, 'w:gz') as tarf: 262 if not is_dir: 263 tarf.add(f"{self.name}.py") 264 else: 265 for root, dirs, files in os.walk(self.name): 266 for f in files: 267 good_file = True 268 fp = os.path.join(root, f) 269 for pattern in patterns_to_ignore: 270 if pattern in str(fp) or f.startswith('.'): 271 good_file = False 272 break 273 if good_file: 274 if debug: 275 dprint(f"Adding '{fp}'...") 276 tarf.add(fp) 277 278 ### clean up and change back to old directory 279 os.chdir(old_cwd) 280 281 ### change to 775 to avoid permissions issues with the API in a Docker container 282 self.archive_path.chmod(0o775) 283 284 if debug: 285 dprint(f"Created archive '{self.archive_path}'.") 286 return self.archive_path 287 288 289 def install( 290 self, 291 skip_deps: bool = False, 292 force: bool = False, 293 debug: bool = False, 294 ) -> SuccessTuple: 295 """ 296 Extract a plugin's tar archive to the plugins directory. 297 298 This function checks if the plugin is already installed and if the version is equal or 299 greater than the existing installation. 300 301 Parameters 302 ---------- 303 skip_deps: bool, default False 304 If `True`, do not install dependencies. 305 306 force: bool, default False 307 If `True`, continue with installation, even if required packages fail to install. 308 309 debug: bool, default False 310 Verbosity toggle. 311 312 Returns 313 ------- 314 A `SuccessTuple` of success (bool) and a message (str). 315 316 """ 317 installation_key = self.full_name 318 with _ongoing_installations_lock: 319 if installation_key in _ongoing_installations: 320 return True, f"Already installing plugin '{self}'." 321 _ongoing_installations.add(installation_key) 322 323 try: 324 from meerschaum.utils.packages import _pip_install_lock 325 with _pip_install_lock(self.name): 326 return self._install_with_rollback( 327 skip_deps=skip_deps, 328 force=force, 329 debug=debug, 330 ) 331 finally: 332 with _ongoing_installations_lock: 333 _ongoing_installations.discard(installation_key) 334 335 336 def _install_with_rollback( 337 self, 338 skip_deps: bool = False, 339 force: bool = False, 340 debug: bool = False, 341 ) -> SuccessTuple: 342 """Restore the previous plugin source and environment after a failed install.""" 343 import tempfile 344 import meerschaum.config.paths as paths 345 from meerschaum.plugins import sync_plugins_symlinks 346 from meerschaum.utils.packages import reload_meerschaum 347 348 if not self.archive_path.exists(): 349 return False, f"Missing archive file for plugin '{self}'." 350 351 paths.PLUGINS_TEMP_RESOURCES_PATH.mkdir(parents=True, exist_ok=True) 352 source_paths = [ 353 plugins_dir_path / filename 354 for plugins_dir_path in paths.PLUGINS_DIR_PATHS 355 for filename in (self.name, self.name + '.py') 356 ] 357 tracked_paths = source_paths + [paths.VIRTENV_RESOURCES_PATH / self.name] 358 359 with tempfile.TemporaryDirectory( 360 prefix=f'.{self.name}-rollback-', 361 dir=paths.PLUGINS_TEMP_RESOURCES_PATH, 362 ) as rollback_dir_str: 363 rollback_dir = pathlib.Path(rollback_dir_str) 364 backups = {} 365 rollback_prepared = False 366 367 def prepare_rollback() -> None: 368 nonlocal rollback_prepared 369 for index, path in enumerate(tracked_paths): 370 if not path.exists() and not path.is_symlink(): 371 continue 372 backup_path = rollback_dir / str(index) 373 _copy_path(path, backup_path) 374 backups[path] = backup_path 375 rollback_prepared = True 376 377 def rollback() -> None: 378 if not rollback_prepared: 379 return 380 for path in tracked_paths: 381 _remove_path(path) 382 for path, backup_path in backups.items(): 383 path.parent.mkdir(parents=True, exist_ok=True) 384 shutil.move(str(backup_path), str(path)) 385 self.__dict__.pop('_module', None) 386 self.__dict__.pop('_required', None) 387 sync_plugins_symlinks(debug=debug) 388 reload_meerschaum(debug=debug) 389 390 try: 391 result = self._install( 392 skip_deps=skip_deps, 393 force=force, 394 debug=debug, 395 _prepare_rollback=prepare_rollback, 396 ) 397 except BaseException: 398 rollback() 399 raise 400 if not result[0]: 401 try: 402 rollback() 403 except Exception as e: 404 return False, result[1] + f"\nFailed to roll back plugin '{self}': {e}" 405 return result 406 407 408 def _install( 409 self, 410 skip_deps: bool = False, 411 force: bool = False, 412 debug: bool = False, 413 _prepare_rollback: Optional[Any] = None, 414 ) -> SuccessTuple: 415 416 import meerschaum.config.paths as paths 417 from meerschaum.utils.warnings import warn, error 418 if debug: 419 from meerschaum.utils.debug import dprint 420 import tarfile 421 import re 422 import ast 423 from meerschaum.plugins import sync_plugins_symlinks 424 from meerschaum.utils.packages import attempt_import, reload_meerschaum 425 from meerschaum.utils.venv import init_venv 426 from meerschaum.utils.misc import safely_extract_tar 427 old_cwd = os.getcwd() 428 old_version = '' 429 new_version = '' 430 temp_dir = paths.PLUGINS_TEMP_RESOURCES_PATH / self.name 431 temp_dir.mkdir(exist_ok=True) 432 433 if not self.archive_path.exists(): 434 return False, f"Missing archive file for plugin '{self}'." 435 if self.version is not None: 436 old_version = self.version 437 if debug: 438 dprint(f"Found existing version '{old_version}' for plugin '{self}'.") 439 440 if debug: 441 dprint(f"Extracting '{self.archive_path}' to '{temp_dir}'...") 442 443 try: 444 with tarfile.open(self.archive_path, 'r:gz') as tarf: 445 safely_extract_tar(tarf, temp_dir) 446 except Exception as e: 447 warn(e) 448 return False, f"Failed to extract plugin '{self.name}'." 449 450 ### search for version information 451 files = os.listdir(temp_dir) 452 453 if str(files[0]) == self.name: 454 is_dir = True 455 elif str(files[0]) == self.name + '.py': 456 is_dir = False 457 else: 458 error(f"Unknown format encountered for plugin '{self}'.") 459 460 fpath = temp_dir / files[0] 461 if is_dir: 462 fpath = fpath / '__init__.py' 463 464 with open(fpath, 'r', encoding='utf-8') as f: 465 init_lines = f.readlines() 466 new_version = None 467 for line in init_lines: 468 if '__version__' not in line: 469 continue 470 version_match = re.search(r'__version__(\s?)=', line.lstrip().rstrip()) 471 if not version_match: 472 continue 473 new_version = ast.literal_eval(line.split('=')[1].lstrip().rstrip()) 474 break 475 if not new_version: 476 warn( 477 f"No `__version__` defined for plugin '{self}'. " 478 + "Assuming new version...", 479 stack = False, 480 ) 481 482 packaging_version = attempt_import('packaging.version') 483 try: 484 is_new_version = (not new_version and not old_version) or ( 485 packaging_version.parse(old_version) < packaging_version.parse(new_version) 486 ) 487 is_same_version = new_version and old_version and ( 488 packaging_version.parse(old_version) == packaging_version.parse(new_version) 489 ) 490 except Exception: 491 is_new_version, is_same_version = True, False 492 493 ### Determine where to permanently store the new plugin. 494 plugin_installation_dir_path = paths.PLUGINS_DIR_PATHS[0] 495 for path in paths.PLUGINS_DIR_PATHS: 496 if not path.exists(): 497 warn(f"Plugins path does not exist: {path}", stack=False) 498 continue 499 500 files_in_plugins_dir = os.listdir(path) 501 if ( 502 self.name in files_in_plugins_dir 503 or 504 (self.name + '.py') in files_in_plugins_dir 505 ): 506 plugin_installation_dir_path = path 507 break 508 509 success_msg = ( 510 f"Successfully installed plugin '{self}'" 511 + ("\n (skipped dependencies)" if skip_deps else "") 512 + "." 513 ) 514 success, abort = None, None 515 516 if is_same_version and not force: 517 success, msg = True, ( 518 f"Plugin '{self}' is up-to-date (version {old_version}).\n" + 519 " Install again with `-f` or `--force` to reinstall." 520 ) 521 abort = True 522 elif is_new_version or force: 523 if _prepare_rollback is not None: 524 _prepare_rollback() 525 source_path = temp_dir / files[0] 526 destination_path = plugin_installation_dir_path / files[0] 527 try: 528 plugin_installation_dir_path.mkdir(parents=True, exist_ok=True) 529 for existing_path in ( 530 plugin_installation_dir_path / self.name, 531 plugin_installation_dir_path / (self.name + '.py'), 532 ): 533 _remove_path(existing_path) 534 if debug: 535 dprint(f"Moving '{source_path}' to '{destination_path}'...") 536 shutil.move(str(source_path), str(destination_path)) 537 except Exception as e: 538 success, msg = False, f"Failed to install plugin '{self}': {e}" 539 else: 540 success, msg = True, success_msg 541 else: 542 success, msg = False, ( 543 f"Your installed version of plugin '{self}' ({old_version}) is higher than " 544 + f"attempted version {new_version}." 545 ) 546 547 shutil.rmtree(temp_dir) 548 os.chdir(old_cwd) 549 550 ### Reload the plugin's module. 551 sync_plugins_symlinks(debug=debug) 552 if '_module' in self.__dict__: 553 del self.__dict__['_module'] 554 init_venv(venv=self.name, force=True, debug=debug) 555 reload_meerschaum(debug=debug) 556 557 ### Record the origin only after a successful transaction. Locally-installed 558 ### plugins have no explicit repo and are intentionally skipped. 559 _origin_repo_keys = ( 560 str(self._repo_connector) 561 if self._repo_connector is not None 562 else self._repo_keys or self._repo_in_name 563 ) 564 565 def write_origin() -> None: 566 if not _origin_repo_keys: 567 return 568 try: 569 from meerschaum.plugins._origins import write_plugin_origin 570 write_plugin_origin( 571 self.name, 572 _origin_repo_keys, 573 plugin_installation_dir_path, 574 debug=debug, 575 ) 576 except Exception: 577 pass 578 579 if abort: 580 write_origin() 581 582 ### if we've already failed, return here 583 if not success or abort: 584 return success, msg 585 586 ### attempt to install dependencies 587 dependencies_installed = skip_deps or self.install_dependencies(force=force, debug=debug) 588 if not dependencies_installed: 589 return False, f"Failed to install dependencies for plugin '{self}'." 590 591 ### handling success tuple, bool, or other (typically None) 592 setup_tuple = self.setup(debug=debug) 593 if isinstance(setup_tuple, tuple): 594 if not setup_tuple[0]: 595 success, msg = setup_tuple 596 elif isinstance(setup_tuple, bool): 597 if not setup_tuple: 598 success, msg = False, ( 599 f"Failed to run post-install setup for plugin '{self}'." + '\n' + 600 f"Check `setup()` in '{self.__file__}' for more information " + 601 "(no error message provided)." 602 ) 603 else: 604 success, msg = True, success_msg 605 elif setup_tuple is None: 606 success = True 607 msg = ( 608 f"Post-install for plugin '{self}' returned None. " + 609 "Assuming plugin successfully installed." 610 ) 611 warn(msg) 612 else: 613 success = False 614 msg = ( 615 f"Post-install for plugin '{self}' returned unexpected value " + 616 f"of type '{type(setup_tuple)}': {setup_tuple}" 617 ) 618 619 _ = self.module 620 if success: 621 write_origin() 622 return success, msg 623 624 625 def remove_archive( 626 self, 627 debug: bool = False 628 ) -> SuccessTuple: 629 """Remove a plugin's archive file.""" 630 if not self.archive_path.exists(): 631 return True, f"Archive file for plugin '{self}' does not exist." 632 try: 633 self.archive_path.unlink() 634 except Exception as e: 635 return False, f"Failed to remove archive for plugin '{self}':\n{e}" 636 return True, "Success" 637 638 639 def remove_venv( 640 self, 641 debug: bool = False 642 ) -> SuccessTuple: 643 """Remove a plugin's virtual environment.""" 644 if not self.venv_path.exists(): 645 return True, f"Virtual environment for plugin '{self}' does not exist." 646 try: 647 shutil.rmtree(self.venv_path) 648 except Exception as e: 649 return False, f"Failed to remove virtual environment for plugin '{self}':\n{e}" 650 return True, "Success" 651 652 653 def uninstall(self, debug: bool = False) -> SuccessTuple: 654 """ 655 Remove a plugin, its virtual environment, and archive file. 656 """ 657 from meerschaum.utils.packages import reload_meerschaum 658 from meerschaum.plugins import sync_plugins_symlinks 659 from meerschaum.utils.warnings import warn, info 660 warnings_thrown_count: int = 0 661 max_warnings: int = 3 662 663 if not self.is_installed(): 664 info( 665 f"Plugin '{self.name}' doesn't seem to be installed.\n " 666 + "Checking for artifacts...", 667 stack = False, 668 ) 669 else: 670 real_path = pathlib.Path(os.path.realpath(self.__file__)) 671 try: 672 if real_path.name == '__init__.py': 673 shutil.rmtree(real_path.parent) 674 else: 675 real_path.unlink() 676 except Exception as e: 677 warn(f"Could not remove source files for plugin '{self.name}':\n{e}", stack=False) 678 warnings_thrown_count += 1 679 else: 680 info(f"Removed source files for plugin '{self.name}'.") 681 682 if self.venv_path.exists(): 683 success, msg = self.remove_venv(debug=debug) 684 if not success: 685 warn(msg, stack=False) 686 warnings_thrown_count += 1 687 else: 688 info(f"Removed virtual environment from plugin '{self.name}'.") 689 690 success = warnings_thrown_count < max_warnings 691 try: 692 from meerschaum.plugins._origins import remove_plugin_origin 693 remove_plugin_origin(self.name, debug=debug) 694 except Exception: 695 pass 696 sync_plugins_symlinks(debug=debug) 697 self.deactivate_venv(force=True, debug=debug) 698 reload_meerschaum(debug=debug) 699 return success, ( 700 f"Successfully uninstalled plugin '{self}'." if success 701 else f"Failed to uninstall plugin '{self}'." 702 ) 703 704 705 def setup(self, *args: str, debug: bool = False, **kw: Any) -> Union[SuccessTuple, bool]: 706 """ 707 If exists, run the plugin's `setup()` function. 708 709 Parameters 710 ---------- 711 *args: str 712 The positional arguments passed to the `setup()` function. 713 714 debug: bool, default False 715 Verbosity toggle. 716 717 **kw: Any 718 The keyword arguments passed to the `setup()` function. 719 720 Returns 721 ------- 722 A `SuccessTuple` or `bool` indicating success. 723 724 """ 725 from meerschaum.utils.debug import dprint 726 import inspect 727 _setup = None 728 for name, fp in inspect.getmembers(self.module): 729 if name == 'setup' and inspect.isfunction(fp): 730 _setup = fp 731 break 732 733 ### assume success if no setup() is found (not necessary) 734 if _setup is None: 735 return True 736 737 sig = inspect.signature(_setup) 738 has_debug, has_kw = ('debug' in sig.parameters), False 739 for k, v in sig.parameters.items(): 740 if '**' in str(v): 741 has_kw = True 742 break 743 744 _kw = {} 745 if has_kw: 746 _kw.update(kw) 747 if has_debug: 748 _kw['debug'] = debug 749 750 if debug: 751 dprint(f"Running setup for plugin '{self}'...") 752 from meerschaum.utils.venv import Venv 753 try: 754 with Venv(self, debug=debug): 755 return_tuple = _setup(*args, **_kw) 756 except Exception as e: 757 return False, str(e) 758 759 if isinstance(return_tuple, tuple): 760 return return_tuple 761 if isinstance(return_tuple, bool): 762 return return_tuple, f"Setup for Plugin '{self.name}' did not return a message." 763 if return_tuple is None: 764 return False, f"Setup for Plugin '{self.name}' returned None." 765 return False, f"Unknown return value from setup for Plugin '{self.name}': {return_tuple}" 766 767 768 def get_dependencies( 769 self, 770 debug: bool = False, 771 ) -> List[str]: 772 """ 773 If the Plugin has specified dependencies in a list called `required`, return the list. 774 775 **NOTE:** Dependecies which start with `'plugin:'` are Meerschaum plugins, not pip packages. 776 Meerschaum plugins may also specify connector keys for a repo after `'@'`. 777 778 Parameters 779 ---------- 780 debug: bool, default False 781 Verbosity toggle. 782 783 Returns 784 ------- 785 A list of required packages and plugins (str). 786 787 """ 788 if '_required' in self.__dict__: 789 return self._required 790 791 ### If the plugin has not yet been imported, 792 ### infer the dependencies from the source text. 793 ### This is not super robust, and it doesn't feel right 794 ### having multiple versions of the logic. 795 ### This is necessary when determining the activation order 796 ### without having import the module. 797 ### For consistency's sake, the module-less method does not cache the requirements. 798 if self.__dict__.get('_module', None) is None: 799 file_path = self.__file__ 800 if file_path is None: 801 return [] 802 with open(file_path, 'r', encoding='utf-8') as f: 803 text = f.read() 804 805 if 'required' not in text: 806 return [] 807 808 ### This has some limitations: 809 ### It relies on `required` being manually declared. 810 ### We lose the ability to dynamically alter the `required` list, 811 ### which is why we've kept the module-reliant method below. 812 import ast, re 813 ### NOTE: This technically would break 814 ### if `required` was the very first line of the file. 815 req_start_match = re.search(r'\nrequired(:\s*)?.*=', text) 816 if not req_start_match: 817 return [] 818 req_start = req_start_match.start() 819 equals_sign = req_start + text[req_start:].find('=') 820 821 ### Dependencies may have brackets within the strings, so push back the index. 822 first_opening_brace = equals_sign + 1 + text[equals_sign:].find('[') 823 if first_opening_brace == -1: 824 return [] 825 826 next_closing_brace = equals_sign + 1 + text[equals_sign:].find(']') 827 if next_closing_brace == -1: 828 return [] 829 830 start_ix = first_opening_brace + 1 831 end_ix = next_closing_brace 832 833 num_braces = 0 834 while True: 835 if '[' not in text[start_ix:end_ix]: 836 break 837 num_braces += 1 838 start_ix = end_ix 839 end_ix += text[end_ix + 1:].find(']') + 1 840 841 req_end = end_ix + 1 842 req_text = ( 843 text[(first_opening_brace-1):req_end] 844 .lstrip() 845 .replace('=', '', 1) 846 .lstrip() 847 .rstrip() 848 ) 849 try: 850 required = ast.literal_eval(req_text) 851 except Exception as e: 852 warn( 853 f"Unable to determine requirements for plugin '{self.name}' " 854 + "without importing the module.\n" 855 + " This may be due to dynamically setting the global `required` list.\n" 856 + f" {e}" 857 ) 858 return [] 859 return required 860 861 import inspect 862 self.activate_venv(dependencies=False, debug=debug) 863 required = [] 864 for name, val in inspect.getmembers(self.module): 865 if name == 'required': 866 required = val 867 break 868 self._required = required 869 self.deactivate_venv(dependencies=False, debug=debug) 870 return required 871 872 873 def get_required_plugins(self, debug: bool=False) -> List[mrsm.plugins.Plugin]: 874 """ 875 Return a list of required Plugin objects. 876 """ 877 from meerschaum.utils.warnings import warn 878 from meerschaum.config import get_config 879 from meerschaum._internal.static import STATIC_CONFIG 880 from meerschaum.connectors.parse import is_valid_connector_keys 881 plugins = [] 882 _deps = self.get_dependencies(debug=debug) 883 sep = STATIC_CONFIG['plugins']['repo_separator'] 884 plugin_names = [ 885 _d[len('plugin:'):] for _d in _deps 886 if _d.startswith('plugin:') and len(_d) > len('plugin:') 887 ] 888 default_repo_keys = get_config('meerschaum', 'repository') 889 skipped_repo_keys = set() 890 891 for _plugin_name in plugin_names: 892 if sep in _plugin_name: 893 try: 894 _plugin_name, _repo_keys = _plugin_name.split(sep) 895 except Exception: 896 _repo_keys = default_repo_keys 897 warn( 898 f"Invalid repo keys for required plugin '{_plugin_name}'.\n " 899 + f"Will try to use '{_repo_keys}' instead.", 900 stack = False, 901 ) 902 else: 903 _repo_keys = default_repo_keys 904 905 if _repo_keys in skipped_repo_keys: 906 continue 907 908 if not is_valid_connector_keys(_repo_keys): 909 warn( 910 f"Invalid connector '{_repo_keys}'.\n" 911 f" Skipping required plugins from repository '{_repo_keys}'", 912 stack=False, 913 ) 914 continue 915 916 plugins.append(Plugin(_plugin_name, repo=_repo_keys)) 917 918 return plugins 919 920 921 def get_required_packages(self, debug: bool=False) -> List[str]: 922 """ 923 Return the required package names (excluding plugins). 924 """ 925 _deps = self.get_dependencies(debug=debug) 926 return [_d for _d in _deps if not _d.startswith('plugin:')] 927 928 929 def activate_venv( 930 self, 931 dependencies: bool = True, 932 init_if_not_exists: bool = True, 933 debug: bool = False, 934 **kw 935 ) -> bool: 936 """ 937 Activate the virtual environments for the plugin and its dependencies. 938 939 Parameters 940 ---------- 941 dependencies: bool, default True 942 If `True`, activate the virtual environments for required plugins. 943 944 Returns 945 ------- 946 A bool indicating success. 947 """ 948 import meerschaum.config.paths as paths 949 from meerschaum.utils.venv import venv_target_path 950 from meerschaum.utils.packages import activate_venv 951 from meerschaum.utils.misc import make_symlink, is_symlink 952 953 if dependencies: 954 for plugin in self.get_required_plugins(debug=debug): 955 plugin.activate_venv(debug=debug, init_if_not_exists=init_if_not_exists, **kw) 956 957 vtp = venv_target_path(self.name, debug=debug, allow_nonexistent=True) 958 venv_meerschaum_path = vtp / 'meerschaum' 959 960 try: 961 success, msg = True, "Success" 962 if is_symlink(venv_meerschaum_path): 963 if pathlib.Path(os.path.realpath(venv_meerschaum_path)) != paths.PACKAGE_ROOT_PATH: 964 venv_meerschaum_path.unlink() 965 success, msg = make_symlink(venv_meerschaum_path, paths.PACKAGE_ROOT_PATH) 966 except Exception as e: 967 success, msg = False, str(e) 968 if not success: 969 warn( 970 f"Unable to create symlink {venv_meerschaum_path} to {paths.PACKAGE_ROOT_PATH}:\n" 971 f"{msg}" 972 ) 973 974 return activate_venv(self.name, init_if_not_exists=init_if_not_exists, debug=debug, **kw) 975 976 977 def deactivate_venv(self, dependencies: bool=True, debug: bool = False, **kw) -> bool: 978 """ 979 Deactivate the virtual environments for the plugin and its dependencies. 980 981 Parameters 982 ---------- 983 dependencies: bool, default True 984 If `True`, deactivate the virtual environments for required plugins. 985 986 Returns 987 ------- 988 A bool indicating success. 989 """ 990 from meerschaum.utils.packages import deactivate_venv 991 success = deactivate_venv(self.name, debug=debug, **kw) 992 if dependencies: 993 for plugin in self.get_required_plugins(debug=debug): 994 plugin.deactivate_venv(debug=debug, **kw) 995 return success 996 997 998 def install_dependencies( 999 self, 1000 force: bool = False, 1001 debug: bool = False, 1002 ) -> bool: 1003 """ 1004 If specified, install dependencies. 1005 1006 **NOTE:** Dependencies that start with `'plugin:'` will be installed as 1007 Meerschaum plugins from the same repository as this Plugin. 1008 To install from a different repository, add the repo keys after `'@'` 1009 (e.g. `'plugin:foo@api:bar'`). 1010 1011 Parameters 1012 ---------- 1013 force: bool, default False 1014 If `True`, continue with the installation, even if some 1015 required packages fail to install. 1016 1017 debug: bool, default False 1018 Verbosity toggle. 1019 1020 Returns 1021 ------- 1022 A bool indicating success. 1023 """ 1024 from meerschaum.utils.packages import pip_install, venv_contains_package 1025 from meerschaum.utils.warnings import warn, info 1026 _deps = self.get_dependencies(debug=debug) 1027 if not _deps and self.requirements_file_path is None: 1028 return True 1029 1030 plugins = self.get_required_plugins(debug=debug) 1031 for _plugin in plugins: 1032 if _plugin.name == self.name: 1033 warn(f"Plugin '{self.name}' cannot depend on itself! Skipping...", stack=False) 1034 continue 1035 _success, _msg = _plugin.repo_connector.install_plugin( 1036 _plugin.name, debug=debug, force=force 1037 ) 1038 if not _success: 1039 warn( 1040 f"Failed to install required plugin '{_plugin}' from '{_plugin.repo_connector}'" 1041 + f" for plugin '{self.name}':\n" + _msg, 1042 stack = False, 1043 ) 1044 if not force: 1045 warn( 1046 "Try installing with the `--force` flag to continue anyway.", 1047 stack = False, 1048 ) 1049 return False 1050 info( 1051 "Continuing with installation despite the failure " 1052 + "(careful, things might be broken!)...", 1053 icon = False 1054 ) 1055 1056 1057 ### First step: parse `requirements.txt` if it exists. 1058 if self.requirements_file_path is not None: 1059 if not pip_install( 1060 requirements_file_path=self.requirements_file_path, 1061 venv=self.name, debug=debug 1062 ): 1063 warn( 1064 f"Failed to resolve 'requirements.txt' for plugin '{self.name}'.", 1065 stack = False, 1066 ) 1067 if not force: 1068 warn( 1069 "Try installing with `--force` to continue anyway.", 1070 stack = False, 1071 ) 1072 return False 1073 info( 1074 "Continuing with installation despite the failure " 1075 + "(careful, things might be broken!)...", 1076 icon = False 1077 ) 1078 1079 1080 ### Don't reinstall packages that are already included in required plugins. 1081 packages = [] 1082 _packages = self.get_required_packages(debug=debug) 1083 accounted_for_packages = set() 1084 for package_name in _packages: 1085 for plugin in plugins: 1086 if venv_contains_package(package_name, plugin.name): 1087 accounted_for_packages.add(package_name) 1088 break 1089 packages = [pkg for pkg in _packages if pkg not in accounted_for_packages] 1090 1091 ### Attempt pip packages installation. 1092 if packages: 1093 for package in packages: 1094 if not pip_install(package, venv=self.name, debug=debug): 1095 warn( 1096 f"Failed to install required package '{package}'" 1097 + f" for plugin '{self.name}'.", 1098 stack = False, 1099 ) 1100 if not force: 1101 warn( 1102 "Try installing with `--force` to continue anyway.", 1103 stack = False, 1104 ) 1105 return False 1106 info( 1107 "Continuing with installation despite the failure " 1108 + "(careful, things might be broken!)...", 1109 icon = False 1110 ) 1111 return True 1112 1113 1114 @property 1115 def full_name(self) -> str: 1116 """ 1117 Include the repo keys with the plugin's name. 1118 """ 1119 from meerschaum._internal.static import STATIC_CONFIG 1120 sep = STATIC_CONFIG['plugins']['repo_separator'] 1121 return self.name + sep + str(self.repo_connector) 1122 1123 1124 def __str__(self): 1125 return self.name 1126 1127 1128 def __repr__(self): 1129 return f"Plugin('{self.name}', repo='{self.repo_connector}')" 1130 1131 1132 def __del__(self): 1133 pass
Handle packaging of Meerschaum plugins.
53 def __init__( 54 self, 55 name: str, 56 version: Optional[str] = None, 57 user_id: Optional[int] = None, 58 required: Optional[List[str]] = None, 59 attributes: Optional[Dict[str, Any]] = None, 60 archive_path: Optional[pathlib.Path] = None, 61 venv_path: Optional[pathlib.Path] = None, 62 repo_connector: Optional['mrsm.connectors.api.APIConnector'] = None, 63 repo: Union['mrsm.connectors.api.APIConnector', str, None] = None, 64 ): 65 import meerschaum.config.paths as paths 66 from meerschaum._internal.static import STATIC_CONFIG 67 sep = STATIC_CONFIG['plugins']['repo_separator'] 68 _repo = None 69 if sep in name: 70 try: 71 name, _repo = name.split(sep) 72 except Exception as e: 73 error(f"Invalid plugin name: '{name}'") 74 self._repo_in_name = _repo 75 76 if attributes is None: 77 attributes = {} 78 self.name = name 79 self.attributes = attributes 80 self.user_id = user_id 81 self._version = version 82 if required: 83 self._required = required 84 self.archive_path = ( 85 archive_path if archive_path is not None 86 else paths.PLUGINS_ARCHIVES_RESOURCES_PATH / f"{self.name}.tar.gz" 87 ) 88 self.venv_path = ( 89 venv_path if venv_path is not None 90 else paths.VIRTENV_RESOURCES_PATH / self.name 91 ) 92 self._repo_connector = repo_connector 93 self._repo_keys = repo
96 @property 97 def repo_connector(self): 98 """ 99 Return the repository connector for this plugin. 100 NOTE: This imports the `connectors` module, which imports certain plugin modules. 101 """ 102 if self._repo_connector is None: 103 from meerschaum.connectors.parse import parse_repo_keys 104 105 repo_keys = self._repo_keys or self._repo_in_name 106 if self._repo_in_name and self._repo_keys and self._repo_keys != self._repo_in_name: 107 error( 108 f"Received inconsistent repos: '{self._repo_in_name}' and '{self._repo_keys}'." 109 ) 110 repo_connector = parse_repo_keys(repo_keys) 111 self._repo_connector = repo_connector 112 return self._repo_connector
Return the repository connector for this plugin.
NOTE: This imports the connectors module, which imports certain plugin modules.
115 @property 116 def version(self): 117 """ 118 Return the plugin's module version is defined (`__version__`) if it's defined. 119 """ 120 if self._version is None: 121 try: 122 self._version = self.module.__version__ 123 except Exception as e: 124 self._version = None 125 return self._version
Return the plugin's module version is defined (__version__) if it's defined.
128 @property 129 def module(self): 130 """ 131 Return the Python module of the underlying plugin, 132 or `None` if the plugin is not installed or its import failed 133 (check `Plugin.import_error` for the swallowed exception). 134 """ 135 if '_module' not in self.__dict__ or self.__dict__.get('_module', None) is None: 136 if self.__file__ is None: 137 return None 138 139 from meerschaum.plugins import import_plugins 140 self._module = import_plugins(str(self), warn=False) 141 142 return self._module
Return the Python module of the underlying plugin,
or None if the plugin is not installed or its import failed
(check Plugin.import_error for the swallowed exception).
144 @property 145 def import_error(self) -> Union[Exception, None]: 146 """ 147 Return the exception raised by this plugin's most recent failed import 148 (i.e. when `Plugin.module` is `None` even though the plugin is installed), 149 otherwise `None`. 150 """ 151 from meerschaum.plugins import _plugins_import_errors 152 return _plugins_import_errors.get(self.name, None)
Return the exception raised by this plugin's most recent failed import
(i.e. when Plugin.module is None even though the plugin is installed),
otherwise None.
180 @property 181 def requirements_file_path(self) -> Union[pathlib.Path, None]: 182 """ 183 If a file named `requirements.txt` exists, return its path. 184 """ 185 if self.__file__ is None: 186 return None 187 path = pathlib.Path(self.__file__).parent / 'requirements.txt' 188 if not path.exists(): 189 return None 190 return path
If a file named requirements.txt exists, return its path.
193 def is_installed(self, **kw) -> bool: 194 """ 195 Check whether a plugin is correctly installed. 196 197 Returns 198 ------- 199 A `bool` indicating whether a plugin exists and is successfully imported. 200 """ 201 return self.__file__ is not None
Check whether a plugin is correctly installed.
Returns
- A
boolindicating whether a plugin exists and is successfully imported.
204 def make_tar(self, debug: bool = False) -> pathlib.Path: 205 """ 206 Compress the plugin's source files into a `.tar.gz` archive and return the archive's path. 207 208 Parameters 209 ---------- 210 debug: bool, default False 211 Verbosity toggle. 212 213 Returns 214 ------- 215 A `pathlib.Path` to the archive file's path. 216 217 """ 218 import tarfile, pathlib, subprocess, fnmatch 219 from meerschaum.utils.debug import dprint 220 from meerschaum.utils.packages import attempt_import 221 pathspec = attempt_import('pathspec', debug=debug) 222 223 if not self.__file__: 224 from meerschaum.utils.warnings import error 225 error(f"Could not find file for plugin '{self}'.") 226 if '__init__.py' in self.__file__ or os.path.isdir(self.__file__): 227 path = self.__file__.replace('__init__.py', '') 228 is_dir = True 229 else: 230 path = self.__file__ 231 is_dir = False 232 233 old_cwd = os.getcwd() 234 real_parent_path = pathlib.Path(os.path.realpath(path)).parent 235 os.chdir(real_parent_path) 236 237 default_patterns_to_ignore = [ 238 '.pyc', 239 '__pycache__/', 240 'eggs/', 241 '__pypackages__/', 242 '.git', 243 ] 244 245 def parse_gitignore() -> 'Set[str]': 246 gitignore_path = pathlib.Path(path) / '.gitignore' 247 if not gitignore_path.exists(): 248 return set(default_patterns_to_ignore) 249 with open(gitignore_path, 'r', encoding='utf-8') as f: 250 gitignore_text = f.read() 251 return set(pathspec.PathSpec.from_lines( 252 pathspec.patterns.GitWildMatchPattern, 253 default_patterns_to_ignore + gitignore_text.splitlines() 254 ).match_tree(path)) 255 256 patterns_to_ignore = parse_gitignore() if is_dir else set() 257 258 if debug: 259 dprint(f"Patterns to ignore:\n{patterns_to_ignore}") 260 261 with tarfile.open(self.archive_path, 'w:gz') as tarf: 262 if not is_dir: 263 tarf.add(f"{self.name}.py") 264 else: 265 for root, dirs, files in os.walk(self.name): 266 for f in files: 267 good_file = True 268 fp = os.path.join(root, f) 269 for pattern in patterns_to_ignore: 270 if pattern in str(fp) or f.startswith('.'): 271 good_file = False 272 break 273 if good_file: 274 if debug: 275 dprint(f"Adding '{fp}'...") 276 tarf.add(fp) 277 278 ### clean up and change back to old directory 279 os.chdir(old_cwd) 280 281 ### change to 775 to avoid permissions issues with the API in a Docker container 282 self.archive_path.chmod(0o775) 283 284 if debug: 285 dprint(f"Created archive '{self.archive_path}'.") 286 return self.archive_path
Compress the plugin's source files into a .tar.gz archive and return the archive's path.
Parameters
- debug (bool, default False): Verbosity toggle.
Returns
- A
pathlib.Pathto the archive file's path.
289 def install( 290 self, 291 skip_deps: bool = False, 292 force: bool = False, 293 debug: bool = False, 294 ) -> SuccessTuple: 295 """ 296 Extract a plugin's tar archive to the plugins directory. 297 298 This function checks if the plugin is already installed and if the version is equal or 299 greater than the existing installation. 300 301 Parameters 302 ---------- 303 skip_deps: bool, default False 304 If `True`, do not install dependencies. 305 306 force: bool, default False 307 If `True`, continue with installation, even if required packages fail to install. 308 309 debug: bool, default False 310 Verbosity toggle. 311 312 Returns 313 ------- 314 A `SuccessTuple` of success (bool) and a message (str). 315 316 """ 317 installation_key = self.full_name 318 with _ongoing_installations_lock: 319 if installation_key in _ongoing_installations: 320 return True, f"Already installing plugin '{self}'." 321 _ongoing_installations.add(installation_key) 322 323 try: 324 from meerschaum.utils.packages import _pip_install_lock 325 with _pip_install_lock(self.name): 326 return self._install_with_rollback( 327 skip_deps=skip_deps, 328 force=force, 329 debug=debug, 330 ) 331 finally: 332 with _ongoing_installations_lock: 333 _ongoing_installations.discard(installation_key)
Extract a plugin's tar archive to the plugins directory.
This function checks if the plugin is already installed and if the version is equal or greater than the existing installation.
Parameters
- skip_deps (bool, default False):
If
True, do not install dependencies. - force (bool, default False):
If
True, continue with installation, even if required packages fail to install. - debug (bool, default False): Verbosity toggle.
Returns
- A
SuccessTupleof success (bool) and a message (str).
625 def remove_archive( 626 self, 627 debug: bool = False 628 ) -> SuccessTuple: 629 """Remove a plugin's archive file.""" 630 if not self.archive_path.exists(): 631 return True, f"Archive file for plugin '{self}' does not exist." 632 try: 633 self.archive_path.unlink() 634 except Exception as e: 635 return False, f"Failed to remove archive for plugin '{self}':\n{e}" 636 return True, "Success"
Remove a plugin's archive file.
639 def remove_venv( 640 self, 641 debug: bool = False 642 ) -> SuccessTuple: 643 """Remove a plugin's virtual environment.""" 644 if not self.venv_path.exists(): 645 return True, f"Virtual environment for plugin '{self}' does not exist." 646 try: 647 shutil.rmtree(self.venv_path) 648 except Exception as e: 649 return False, f"Failed to remove virtual environment for plugin '{self}':\n{e}" 650 return True, "Success"
Remove a plugin's virtual environment.
653 def uninstall(self, debug: bool = False) -> SuccessTuple: 654 """ 655 Remove a plugin, its virtual environment, and archive file. 656 """ 657 from meerschaum.utils.packages import reload_meerschaum 658 from meerschaum.plugins import sync_plugins_symlinks 659 from meerschaum.utils.warnings import warn, info 660 warnings_thrown_count: int = 0 661 max_warnings: int = 3 662 663 if not self.is_installed(): 664 info( 665 f"Plugin '{self.name}' doesn't seem to be installed.\n " 666 + "Checking for artifacts...", 667 stack = False, 668 ) 669 else: 670 real_path = pathlib.Path(os.path.realpath(self.__file__)) 671 try: 672 if real_path.name == '__init__.py': 673 shutil.rmtree(real_path.parent) 674 else: 675 real_path.unlink() 676 except Exception as e: 677 warn(f"Could not remove source files for plugin '{self.name}':\n{e}", stack=False) 678 warnings_thrown_count += 1 679 else: 680 info(f"Removed source files for plugin '{self.name}'.") 681 682 if self.venv_path.exists(): 683 success, msg = self.remove_venv(debug=debug) 684 if not success: 685 warn(msg, stack=False) 686 warnings_thrown_count += 1 687 else: 688 info(f"Removed virtual environment from plugin '{self.name}'.") 689 690 success = warnings_thrown_count < max_warnings 691 try: 692 from meerschaum.plugins._origins import remove_plugin_origin 693 remove_plugin_origin(self.name, debug=debug) 694 except Exception: 695 pass 696 sync_plugins_symlinks(debug=debug) 697 self.deactivate_venv(force=True, debug=debug) 698 reload_meerschaum(debug=debug) 699 return success, ( 700 f"Successfully uninstalled plugin '{self}'." if success 701 else f"Failed to uninstall plugin '{self}'." 702 )
Remove a plugin, its virtual environment, and archive file.
705 def setup(self, *args: str, debug: bool = False, **kw: Any) -> Union[SuccessTuple, bool]: 706 """ 707 If exists, run the plugin's `setup()` function. 708 709 Parameters 710 ---------- 711 *args: str 712 The positional arguments passed to the `setup()` function. 713 714 debug: bool, default False 715 Verbosity toggle. 716 717 **kw: Any 718 The keyword arguments passed to the `setup()` function. 719 720 Returns 721 ------- 722 A `SuccessTuple` or `bool` indicating success. 723 724 """ 725 from meerschaum.utils.debug import dprint 726 import inspect 727 _setup = None 728 for name, fp in inspect.getmembers(self.module): 729 if name == 'setup' and inspect.isfunction(fp): 730 _setup = fp 731 break 732 733 ### assume success if no setup() is found (not necessary) 734 if _setup is None: 735 return True 736 737 sig = inspect.signature(_setup) 738 has_debug, has_kw = ('debug' in sig.parameters), False 739 for k, v in sig.parameters.items(): 740 if '**' in str(v): 741 has_kw = True 742 break 743 744 _kw = {} 745 if has_kw: 746 _kw.update(kw) 747 if has_debug: 748 _kw['debug'] = debug 749 750 if debug: 751 dprint(f"Running setup for plugin '{self}'...") 752 from meerschaum.utils.venv import Venv 753 try: 754 with Venv(self, debug=debug): 755 return_tuple = _setup(*args, **_kw) 756 except Exception as e: 757 return False, str(e) 758 759 if isinstance(return_tuple, tuple): 760 return return_tuple 761 if isinstance(return_tuple, bool): 762 return return_tuple, f"Setup for Plugin '{self.name}' did not return a message." 763 if return_tuple is None: 764 return False, f"Setup for Plugin '{self.name}' returned None." 765 return False, f"Unknown return value from setup for Plugin '{self.name}': {return_tuple}"
768 def get_dependencies( 769 self, 770 debug: bool = False, 771 ) -> List[str]: 772 """ 773 If the Plugin has specified dependencies in a list called `required`, return the list. 774 775 **NOTE:** Dependecies which start with `'plugin:'` are Meerschaum plugins, not pip packages. 776 Meerschaum plugins may also specify connector keys for a repo after `'@'`. 777 778 Parameters 779 ---------- 780 debug: bool, default False 781 Verbosity toggle. 782 783 Returns 784 ------- 785 A list of required packages and plugins (str). 786 787 """ 788 if '_required' in self.__dict__: 789 return self._required 790 791 ### If the plugin has not yet been imported, 792 ### infer the dependencies from the source text. 793 ### This is not super robust, and it doesn't feel right 794 ### having multiple versions of the logic. 795 ### This is necessary when determining the activation order 796 ### without having import the module. 797 ### For consistency's sake, the module-less method does not cache the requirements. 798 if self.__dict__.get('_module', None) is None: 799 file_path = self.__file__ 800 if file_path is None: 801 return [] 802 with open(file_path, 'r', encoding='utf-8') as f: 803 text = f.read() 804 805 if 'required' not in text: 806 return [] 807 808 ### This has some limitations: 809 ### It relies on `required` being manually declared. 810 ### We lose the ability to dynamically alter the `required` list, 811 ### which is why we've kept the module-reliant method below. 812 import ast, re 813 ### NOTE: This technically would break 814 ### if `required` was the very first line of the file. 815 req_start_match = re.search(r'\nrequired(:\s*)?.*=', text) 816 if not req_start_match: 817 return [] 818 req_start = req_start_match.start() 819 equals_sign = req_start + text[req_start:].find('=') 820 821 ### Dependencies may have brackets within the strings, so push back the index. 822 first_opening_brace = equals_sign + 1 + text[equals_sign:].find('[') 823 if first_opening_brace == -1: 824 return [] 825 826 next_closing_brace = equals_sign + 1 + text[equals_sign:].find(']') 827 if next_closing_brace == -1: 828 return [] 829 830 start_ix = first_opening_brace + 1 831 end_ix = next_closing_brace 832 833 num_braces = 0 834 while True: 835 if '[' not in text[start_ix:end_ix]: 836 break 837 num_braces += 1 838 start_ix = end_ix 839 end_ix += text[end_ix + 1:].find(']') + 1 840 841 req_end = end_ix + 1 842 req_text = ( 843 text[(first_opening_brace-1):req_end] 844 .lstrip() 845 .replace('=', '', 1) 846 .lstrip() 847 .rstrip() 848 ) 849 try: 850 required = ast.literal_eval(req_text) 851 except Exception as e: 852 warn( 853 f"Unable to determine requirements for plugin '{self.name}' " 854 + "without importing the module.\n" 855 + " This may be due to dynamically setting the global `required` list.\n" 856 + f" {e}" 857 ) 858 return [] 859 return required 860 861 import inspect 862 self.activate_venv(dependencies=False, debug=debug) 863 required = [] 864 for name, val in inspect.getmembers(self.module): 865 if name == 'required': 866 required = val 867 break 868 self._required = required 869 self.deactivate_venv(dependencies=False, debug=debug) 870 return required
If the Plugin has specified dependencies in a list called required, return the list.
NOTE: Dependecies which start with 'plugin:' are Meerschaum plugins, not pip packages.
Meerschaum plugins may also specify connector keys for a repo after '@'.
Parameters
- debug (bool, default False): Verbosity toggle.
Returns
- A list of required packages and plugins (str).
873 def get_required_plugins(self, debug: bool=False) -> List[mrsm.plugins.Plugin]: 874 """ 875 Return a list of required Plugin objects. 876 """ 877 from meerschaum.utils.warnings import warn 878 from meerschaum.config import get_config 879 from meerschaum._internal.static import STATIC_CONFIG 880 from meerschaum.connectors.parse import is_valid_connector_keys 881 plugins = [] 882 _deps = self.get_dependencies(debug=debug) 883 sep = STATIC_CONFIG['plugins']['repo_separator'] 884 plugin_names = [ 885 _d[len('plugin:'):] for _d in _deps 886 if _d.startswith('plugin:') and len(_d) > len('plugin:') 887 ] 888 default_repo_keys = get_config('meerschaum', 'repository') 889 skipped_repo_keys = set() 890 891 for _plugin_name in plugin_names: 892 if sep in _plugin_name: 893 try: 894 _plugin_name, _repo_keys = _plugin_name.split(sep) 895 except Exception: 896 _repo_keys = default_repo_keys 897 warn( 898 f"Invalid repo keys for required plugin '{_plugin_name}'.\n " 899 + f"Will try to use '{_repo_keys}' instead.", 900 stack = False, 901 ) 902 else: 903 _repo_keys = default_repo_keys 904 905 if _repo_keys in skipped_repo_keys: 906 continue 907 908 if not is_valid_connector_keys(_repo_keys): 909 warn( 910 f"Invalid connector '{_repo_keys}'.\n" 911 f" Skipping required plugins from repository '{_repo_keys}'", 912 stack=False, 913 ) 914 continue 915 916 plugins.append(Plugin(_plugin_name, repo=_repo_keys)) 917 918 return plugins
Return a list of required Plugin objects.
921 def get_required_packages(self, debug: bool=False) -> List[str]: 922 """ 923 Return the required package names (excluding plugins). 924 """ 925 _deps = self.get_dependencies(debug=debug) 926 return [_d for _d in _deps if not _d.startswith('plugin:')]
Return the required package names (excluding plugins).
929 def activate_venv( 930 self, 931 dependencies: bool = True, 932 init_if_not_exists: bool = True, 933 debug: bool = False, 934 **kw 935 ) -> bool: 936 """ 937 Activate the virtual environments for the plugin and its dependencies. 938 939 Parameters 940 ---------- 941 dependencies: bool, default True 942 If `True`, activate the virtual environments for required plugins. 943 944 Returns 945 ------- 946 A bool indicating success. 947 """ 948 import meerschaum.config.paths as paths 949 from meerschaum.utils.venv import venv_target_path 950 from meerschaum.utils.packages import activate_venv 951 from meerschaum.utils.misc import make_symlink, is_symlink 952 953 if dependencies: 954 for plugin in self.get_required_plugins(debug=debug): 955 plugin.activate_venv(debug=debug, init_if_not_exists=init_if_not_exists, **kw) 956 957 vtp = venv_target_path(self.name, debug=debug, allow_nonexistent=True) 958 venv_meerschaum_path = vtp / 'meerschaum' 959 960 try: 961 success, msg = True, "Success" 962 if is_symlink(venv_meerschaum_path): 963 if pathlib.Path(os.path.realpath(venv_meerschaum_path)) != paths.PACKAGE_ROOT_PATH: 964 venv_meerschaum_path.unlink() 965 success, msg = make_symlink(venv_meerschaum_path, paths.PACKAGE_ROOT_PATH) 966 except Exception as e: 967 success, msg = False, str(e) 968 if not success: 969 warn( 970 f"Unable to create symlink {venv_meerschaum_path} to {paths.PACKAGE_ROOT_PATH}:\n" 971 f"{msg}" 972 ) 973 974 return activate_venv(self.name, init_if_not_exists=init_if_not_exists, debug=debug, **kw)
Activate the virtual environments for the plugin and its dependencies.
Parameters
- dependencies (bool, default True):
If
True, activate the virtual environments for required plugins.
Returns
- A bool indicating success.
977 def deactivate_venv(self, dependencies: bool=True, debug: bool = False, **kw) -> bool: 978 """ 979 Deactivate the virtual environments for the plugin and its dependencies. 980 981 Parameters 982 ---------- 983 dependencies: bool, default True 984 If `True`, deactivate the virtual environments for required plugins. 985 986 Returns 987 ------- 988 A bool indicating success. 989 """ 990 from meerschaum.utils.packages import deactivate_venv 991 success = deactivate_venv(self.name, debug=debug, **kw) 992 if dependencies: 993 for plugin in self.get_required_plugins(debug=debug): 994 plugin.deactivate_venv(debug=debug, **kw) 995 return success
Deactivate the virtual environments for the plugin and its dependencies.
Parameters
- dependencies (bool, default True):
If
True, deactivate the virtual environments for required plugins.
Returns
- A bool indicating success.
998 def install_dependencies( 999 self, 1000 force: bool = False, 1001 debug: bool = False, 1002 ) -> bool: 1003 """ 1004 If specified, install dependencies. 1005 1006 **NOTE:** Dependencies that start with `'plugin:'` will be installed as 1007 Meerschaum plugins from the same repository as this Plugin. 1008 To install from a different repository, add the repo keys after `'@'` 1009 (e.g. `'plugin:foo@api:bar'`). 1010 1011 Parameters 1012 ---------- 1013 force: bool, default False 1014 If `True`, continue with the installation, even if some 1015 required packages fail to install. 1016 1017 debug: bool, default False 1018 Verbosity toggle. 1019 1020 Returns 1021 ------- 1022 A bool indicating success. 1023 """ 1024 from meerschaum.utils.packages import pip_install, venv_contains_package 1025 from meerschaum.utils.warnings import warn, info 1026 _deps = self.get_dependencies(debug=debug) 1027 if not _deps and self.requirements_file_path is None: 1028 return True 1029 1030 plugins = self.get_required_plugins(debug=debug) 1031 for _plugin in plugins: 1032 if _plugin.name == self.name: 1033 warn(f"Plugin '{self.name}' cannot depend on itself! Skipping...", stack=False) 1034 continue 1035 _success, _msg = _plugin.repo_connector.install_plugin( 1036 _plugin.name, debug=debug, force=force 1037 ) 1038 if not _success: 1039 warn( 1040 f"Failed to install required plugin '{_plugin}' from '{_plugin.repo_connector}'" 1041 + f" for plugin '{self.name}':\n" + _msg, 1042 stack = False, 1043 ) 1044 if not force: 1045 warn( 1046 "Try installing with the `--force` flag to continue anyway.", 1047 stack = False, 1048 ) 1049 return False 1050 info( 1051 "Continuing with installation despite the failure " 1052 + "(careful, things might be broken!)...", 1053 icon = False 1054 ) 1055 1056 1057 ### First step: parse `requirements.txt` if it exists. 1058 if self.requirements_file_path is not None: 1059 if not pip_install( 1060 requirements_file_path=self.requirements_file_path, 1061 venv=self.name, debug=debug 1062 ): 1063 warn( 1064 f"Failed to resolve 'requirements.txt' for plugin '{self.name}'.", 1065 stack = False, 1066 ) 1067 if not force: 1068 warn( 1069 "Try installing with `--force` to continue anyway.", 1070 stack = False, 1071 ) 1072 return False 1073 info( 1074 "Continuing with installation despite the failure " 1075 + "(careful, things might be broken!)...", 1076 icon = False 1077 ) 1078 1079 1080 ### Don't reinstall packages that are already included in required plugins. 1081 packages = [] 1082 _packages = self.get_required_packages(debug=debug) 1083 accounted_for_packages = set() 1084 for package_name in _packages: 1085 for plugin in plugins: 1086 if venv_contains_package(package_name, plugin.name): 1087 accounted_for_packages.add(package_name) 1088 break 1089 packages = [pkg for pkg in _packages if pkg not in accounted_for_packages] 1090 1091 ### Attempt pip packages installation. 1092 if packages: 1093 for package in packages: 1094 if not pip_install(package, venv=self.name, debug=debug): 1095 warn( 1096 f"Failed to install required package '{package}'" 1097 + f" for plugin '{self.name}'.", 1098 stack = False, 1099 ) 1100 if not force: 1101 warn( 1102 "Try installing with `--force` to continue anyway.", 1103 stack = False, 1104 ) 1105 return False 1106 info( 1107 "Continuing with installation despite the failure " 1108 + "(careful, things might be broken!)...", 1109 icon = False 1110 ) 1111 return True
If specified, install dependencies.
NOTE: Dependencies that start with 'plugin:' will be installed as
Meerschaum plugins from the same repository as this Plugin.
To install from a different repository, add the repo keys after '@'
(e.g. 'plugin:foo@api:bar').
Parameters
- force (bool, default False):
If
True, continue with the installation, even if some required packages fail to install. - debug (bool, default False): Verbosity toggle.
Returns
- A bool indicating success.
1114 @property 1115 def full_name(self) -> str: 1116 """ 1117 Include the repo keys with the plugin's name. 1118 """ 1119 from meerschaum._internal.static import STATIC_CONFIG 1120 sep = STATIC_CONFIG['plugins']['repo_separator'] 1121 return self.name + sep + str(self.repo_connector)
Include the repo keys with the plugin's name.
77def make_action( 78 function: Optional[Callable[[Any], Any]] = None, 79 shell: bool = False, 80 activate: bool = True, 81 deactivate: bool = True, 82 debug: bool = False, 83 daemon: bool = True, 84 skip_if_loaded: bool = True, 85 _plugin_name: Optional[str] = None, 86) -> Callable[[Any], Any]: 87 """ 88 Make a function a Meerschaum action. Useful for plugins that are adding multiple actions. 89 90 Parameters 91 ---------- 92 function: Callable[[Any], Any] 93 The function to become a Meerschaum action. Must accept all keyword arguments. 94 95 shell: bool, default False 96 Not used. 97 98 Returns 99 ------- 100 Another function (this is a decorator function). 101 102 Examples 103 -------- 104 >>> from meerschaum.plugins import make_action 105 >>> 106 >>> @make_action 107 ... def my_action(**kw): 108 ... print('foo') 109 ... return True, "Success" 110 >>> 111 """ 112 def _decorator(func: Callable[[Any], Any]) -> Callable[[Any], Any]: 113 from meerschaum.actions import actions, _custom_actions_plugins, _plugins_actions 114 if skip_if_loaded and func.__name__ in actions: 115 return func 116 117 plugin_name = _plugin_name or _get_parent_plugin(function=func) 118 plugin = Plugin(plugin_name) if plugin_name else None 119 120 if debug: 121 from meerschaum.utils.debug import dprint 122 dprint( 123 f"Adding action '{func.__name__}' from plugin " 124 f"'{plugin}'..." 125 ) 126 127 actions[func.__name__] = func 128 _custom_actions_plugins[func.__name__] = plugin_name 129 if plugin_name not in _plugins_actions: 130 _plugins_actions[plugin_name] = [] 131 _plugins_actions[plugin_name].append(func.__name__) 132 if not daemon: 133 _actions_daemon_enabled[func.__name__] = False 134 return func 135 136 if function is None: 137 return _decorator 138 return _decorator(function)
Make a function a Meerschaum action. Useful for plugins that are adding multiple actions.
Parameters
- function (Callable[[Any], Any]): The function to become a Meerschaum action. Must accept all keyword arguments.
- shell (bool, default False): Not used.
Returns
- Another function (this is a decorator function).
Examples
>>> from meerschaum.plugins import make_action
>>>
>>> @make_action
... def my_action(**kw):
... print('foo')
... return True, "Success"
>>>
321def api_plugin(function: Callable[[Any], Any]) -> Callable[[Any], Any]: 322 """ 323 Execute the function when initializing the Meerschaum API module. 324 Useful for lazy-loading heavy plugins only when the API is started, 325 such as when editing the `meerschaum.api.app` FastAPI app. 326 327 The FastAPI app will be passed as the only parameter. 328 329 Examples 330 -------- 331 >>> from meerschaum.plugins import api_plugin 332 >>> 333 >>> @api_plugin 334 >>> def initialize_plugin(app): 335 ... @app.get('/my/new/path') 336 ... def new_path(): 337 ... return {'message': 'It works!'} 338 >>> 339 """ 340 with _locks['_api_plugins']: 341 try: 342 plugin_name = _get_parent_plugin(function=function) 343 if plugin_name not in _api_plugins: 344 _api_plugins[plugin_name] = [] 345 _api_plugins[plugin_name].append(function) 346 except Exception as e: 347 from meerschaum.utils.warnings import warn 348 warn(e) 349 return function
Execute the function when initializing the Meerschaum API module.
Useful for lazy-loading heavy plugins only when the API is started,
such as when editing the meerschaum.api.app FastAPI app.
The FastAPI app will be passed as the only parameter.
Examples
>>> from meerschaum.plugins import api_plugin
>>>
>>> @api_plugin
>>> def initialize_plugin(app):
... @app.get('/my/new/path')
... def new_path():
... return {'message': 'It works!'}
>>>
305def dash_plugin(function: Callable[[Any], Any]) -> Callable[[Any], Any]: 306 """ 307 Execute the function when starting the Dash application. 308 """ 309 with _locks['_dash_plugins']: 310 plugin_name = _get_parent_plugin(function=function) 311 try: 312 if plugin_name not in _dash_plugins: 313 _dash_plugins[plugin_name] = [] 314 _dash_plugins[plugin_name].append(function) 315 except Exception as e: 316 from meerschaum.utils.warnings import warn 317 warn(e) 318 return function
Execute the function when starting the Dash application.
219def web_page( 220 page: Union[str, None, Callable[[Any], Any]] = None, 221 login_required: bool = True, 222 skip_navbar: bool = False, 223 page_group: Optional[str] = None, 224 dark_theme: bool = True, 225 **kwargs 226) -> Any: 227 """ 228 Quickly add pages to the dash application. 229 230 Parameters 231 ---------- 232 dark_theme: bool, default True 233 If `True`, apply the Web Console's `dbc_dark` component theme to this page 234 (the default — most plugins want this). Set to `False` to opt out: the 235 `dbc_dark` class is removed from `<body>` while this page is active, so a 236 plugin's own styling applies without competing with the theme's overrides. 237 Note the global base Bootstrap theme (dark background) still applies, so an 238 opted-out page should paint its own background. 239 240 Examples 241 -------- 242 >>> import meerschaum as mrsm 243 >>> from meerschaum.plugins import web_page 244 >>> html = mrsm.attempt_import('dash.html') 245 >>> 246 >>> @web_page('foo/bar', login_required=False) 247 >>> def foo_bar(): 248 ... return html.Div([html.H1("Hello, World!")]) 249 >>> 250 """ 251 page_str = None 252 253 def _decorator(_func: Callable[[Any], Any]) -> Callable[[Any], Any]: 254 nonlocal page_str, page_group 255 256 @functools.wraps(_func) 257 def wrapper(*_args, **_kwargs): 258 return _func(*_args, **_kwargs) 259 260 if page_str is None: 261 page_str = _func.__name__ 262 263 page_str = page_str.lstrip('/').rstrip('/').strip() 264 if not page_str.startswith('dash'): 265 page_str = f'/dash/{page_str}' 266 page_key = ( 267 ' '.join( 268 [ 269 word.capitalize() 270 for word in ( 271 page_str.replace('/dash', '').lstrip('/').rstrip('/').strip() 272 .replace('-', ' ').replace('_', ' ').split(' ') 273 ) 274 ] 275 ) 276 ) 277 278 plugin_name = _get_parent_plugin(function=_func) 279 page_group = page_group or plugin_name 280 if page_group not in _plugin_endpoints_to_pages: 281 _plugin_endpoints_to_pages[page_group] = {} 282 _plugin_endpoints_to_pages[page_group][page_str] = { 283 'function': _func, 284 'login_required': login_required, 285 'skip_navbar': skip_navbar, 286 'page_key': page_key, 287 'dark_theme': dark_theme, 288 } 289 if plugin_name not in _plugins_web_pages: 290 _plugins_web_pages[plugin_name] = [] 291 _plugins_web_pages[plugin_name].append(_func) 292 return wrapper 293 294 if callable(page): 295 decorator_to_return = _decorator(page) 296 page_str = page.__name__ 297 else: 298 decorator_to_return = _decorator 299 page_str = page 300 301 return decorator_to_return
Quickly add pages to the dash application.
Parameters
- dark_theme (bool, default True):
If
True, apply the Web Console'sdbc_darkcomponent theme to this page (the default — most plugins want this). Set toFalseto opt out: thedbc_darkclass is removed from<body>while this page is active, so a plugin's own styling applies without competing with the theme's overrides. Note the global base Bootstrap theme (dark background) still applies, so an opted-out page should paint its own background.
Examples
>>> import meerschaum as mrsm
>>> from meerschaum.plugins import web_page
>>> html = mrsm.attempt_import('dash.html')
>>>
>>> @web_page('foo/bar', login_required=False)
>>> def foo_bar():
... return html.Div([html.H1("Hello, World!")])
>>>
511def import_plugins( 512 *plugins_to_import: Union[str, List[str], None], 513 warn: bool = True, 514) -> Union[ 515 'ModuleType', Tuple['ModuleType', None] 516]: 517 """ 518 Import the Meerschaum plugins directory. 519 520 Parameters 521 ---------- 522 plugins_to_import: Union[str, List[str], None] 523 If provided, only import the specified plugins. 524 Otherwise import the entire plugins module. May be a string, list, or `None`. 525 Defaults to `None`. 526 527 Returns 528 ------- 529 A module of list of modules, depening on the number of plugins provided. 530 531 """ 532 import sys 533 import importlib 534 import meerschaum.config.paths as paths 535 from meerschaum.utils.misc import flatten_list 536 from meerschaum.utils.venv import is_venv_active, activate_venv, deactivate_venv, Venv 537 from meerschaum.utils.warnings import warn as _warn 538 plugins_to_import = list(plugins_to_import) 539 prepended_sys_path = False 540 with _locks['sys.path']: 541 542 ### Since plugins may depend on other plugins, 543 ### we need to activate the virtual environments for library plugins. 544 ### This logic exists in `Plugin.activate_venv()`, 545 ### but that code requires the plugin's module to already be imported. 546 ### It's not a guarantee of correct activation order, 547 ### e.g. if a library plugin pins a specific package and another 548 plugins_names = get_plugins_names() 549 already_active_venvs = { 550 plugin_name 551 for plugin_name in plugins_names 552 if is_venv_active(plugin_name) 553 } 554 555 if not sys.path or sys.path[0] != str(paths.PLUGINS_RESOURCES_PATH.parent): 556 prepended_sys_path = True 557 sys.path.insert(0, str(paths.PLUGINS_RESOURCES_PATH.parent)) 558 559 if not plugins_to_import: 560 for plugin_name in plugins_names: 561 activate_venv(plugin_name) 562 try: 563 imported_plugins = importlib.import_module(paths.PLUGINS_RESOURCES_PATH.stem) 564 except ImportError as e: 565 _warn(f"Failed to import the plugins module:\n {e}") 566 import traceback 567 traceback.print_exc() 568 imported_plugins = None 569 for plugin_name in plugins_names: 570 if plugin_name in already_active_venvs: 571 continue 572 deactivate_venv(plugin_name) 573 574 else: 575 imported_plugins = [] 576 for plugin_name in flatten_list(plugins_to_import): 577 plugin = Plugin(plugin_name) 578 try: 579 with Venv(plugin, init_if_not_exists=False): 580 imported_plugins.append( 581 importlib.import_module( 582 f'{paths.PLUGINS_RESOURCES_PATH.stem}.{plugin_name}' 583 ) 584 ) 585 _ = _plugins_import_errors.pop(plugin_name, None) 586 except Exception as e: 587 _plugins_import_errors[plugin_name] = e 588 _warn( 589 f"Failed to import plugin '{plugin_name}':\n " 590 + f"{e}\n\nHere's a stacktrace:", 591 stack = False, 592 ) 593 from meerschaum.utils.formatting import get_console 594 get_console().print_exception( 595 suppress = [ 596 'meerschaum/plugins/__init__.py', 597 importlib, 598 importlib._bootstrap, 599 ] 600 ) 601 imported_plugins.append(None) 602 603 if imported_plugins is None and warn: 604 _warn("Failed to import plugins.", stacklevel=3) 605 606 if prepended_sys_path and str(paths.PLUGINS_RESOURCES_PATH.parent) in sys.path: 607 sys.path.remove(str(paths.PLUGINS_RESOURCES_PATH.parent)) 608 609 if isinstance(imported_plugins, list): 610 return (imported_plugins[0] if len(imported_plugins) == 1 else tuple(imported_plugins)) 611 return imported_plugins
Import the Meerschaum plugins directory.
Parameters
- plugins_to_import (Union[str, List[str], None]):
If provided, only import the specified plugins.
Otherwise import the entire plugins module. May be a string, list, or
None. Defaults toNone.
Returns
- A module of list of modules, depening on the number of plugins provided.
614def from_plugin_import(plugin_import_name: str, *attrs: str) -> Any: 615 """ 616 Emulate the `from module import x` behavior. 617 618 Parameters 619 ---------- 620 plugin_import_name: str 621 The import name of the plugin's module. 622 Separate submodules with '.' (e.g. 'compose.utils.pipes') 623 624 attrs: str 625 Names of the attributes to return. 626 627 Returns 628 ------- 629 Objects from a plugin's submodule. 630 If multiple objects are provided, return a tuple. 631 632 Examples 633 -------- 634 >>> init = from_plugin_import('compose.utils', 'init') 635 >>> with mrsm.Venv('compose'): 636 ... cf = init() 637 >>> build_parent_pipe, get_defined_pipes = from_plugin_import( 638 ... 'compose.utils.pipes', 639 ... 'build_parent_pipe', 640 ... 'get_defined_pipes', 641 ... ) 642 >>> parent_pipe = build_parent_pipe(cf) 643 >>> defined_pipes = get_defined_pipes(cf) 644 """ 645 import importlib 646 import meerschaum.config.paths as paths 647 from meerschaum.utils.warnings import warn as _warn 648 if plugin_import_name.startswith('plugins.'): 649 plugin_import_name = plugin_import_name[len('plugins.'):] 650 plugin_import_parts = plugin_import_name.split('.') 651 plugin_root_name = plugin_import_parts[0] 652 653 submodule_import_name = '.'.join( 654 [paths.PLUGINS_RESOURCES_PATH.stem] 655 + plugin_import_parts 656 ) 657 if len(attrs) == 0: 658 raise ValueError(f"Provide which attributes to return from '{submodule_import_name}'.") 659 660 first_party_submodule = _import_first_party_plugin(plugin_import_name) 661 if first_party_submodule is not None: 662 attrs_to_return = [getattr(first_party_submodule, attr) for attr in attrs] 663 return attrs_to_return[0] if len(attrs_to_return) == 1 else tuple(attrs_to_return) 664 665 plugin = mrsm.Plugin(plugin_root_name) 666 attrs_to_return = [] 667 with mrsm.Venv(plugin): 668 if plugin.module is None: 669 raise ImportError(f"Unable to import plugin '{plugin}'.") 670 671 try: 672 submodule = importlib.import_module(submodule_import_name) 673 except ImportError as e: 674 _warn( 675 f"Failed to import plugin '{submodule_import_name}':\n " 676 + f"{e}\n\nHere's a stacktrace:", 677 stack=False, 678 ) 679 from meerschaum.utils.formatting import get_console 680 get_console().print_exception( 681 suppress=[ 682 'meerschaum/plugins/__init__.py', 683 importlib, 684 importlib._bootstrap, 685 ] 686 ) 687 return None 688 689 for attr in attrs: 690 try: 691 attrs_to_return.append(getattr(submodule, attr)) 692 except Exception: 693 _warn(f"Failed to access '{attr}' from '{submodule_import_name}'.") 694 attrs_to_return.append(None) 695 696 if len(attrs) == 1: 697 return attrs_to_return[0] 698 699 return tuple(attrs_to_return)
Emulate the from module import x behavior.
Parameters
- plugin_import_name (str): The import name of the plugin's module. Separate submodules with '.' (e.g. 'compose.utils.pipes')
- attrs (str): Names of the attributes to return.
Returns
- Objects from a plugin's submodule.
- If multiple objects are provided, return a tuple.
Examples
>>> init = from_plugin_import('compose.utils', 'init')
>>> with mrsm.Venv('compose'):
... cf = init()
>>> build_parent_pipe, get_defined_pipes = from_plugin_import(
... 'compose.utils.pipes',
... 'build_parent_pipe',
... 'get_defined_pipes',
... )
>>> parent_pipe = build_parent_pipe(cf)
>>> defined_pipes = get_defined_pipes(cf)
912def invalidate_plugins_cache() -> None: 913 """ 914 Pop the cached `plugins` package and its submodules from `sys.modules` 915 and reset the loaded-plugins state. 916 917 The `plugins` package caches its `__path__` (the resolved 918 `PLUGINS_RESOURCES_PATH`) at import time. When the active root or 919 plugins-dir scope changes in-process (e.g. via 920 `meerschaum.config.environment.replace_env`), that stale `__path__` 921 makes subsequent plugin imports re-discover plugins under the previous 922 scope's `.internal/plugins` directory. Call this whenever the plugins 923 scope changes so the next import rebuilds against the current 924 `PLUGINS_RESOURCES_PATH`. 925 """ 926 global _loaded_plugins, _synced_symlinks 927 import sys 928 import meerschaum.config.paths as paths 929 930 plugins_stem = paths.PLUGINS_RESOURCES_PATH.stem 931 module_prefix = plugins_stem + '.' 932 for mod_name in [ 933 mod_name 934 for mod_name in sys.modules 935 if mod_name == plugins_stem or mod_name.startswith(module_prefix) 936 ]: 937 _ = sys.modules.pop(mod_name, None) 938 939 _loaded_plugins = False 940 ### Also reset the symlinks counter — it's per-scope state, and leaving it 941 ### >1 makes `sync_plugins_symlinks` skip creating the new scope's 942 ### `.internal/plugins` (so `plugins` imports as an empty namespace package). 943 _synced_symlinks = 0
Pop the cached plugins package and its submodules from sys.modules
and reset the loaded-plugins state.
The plugins package caches its __path__ (the resolved
PLUGINS_RESOURCES_PATH) at import time. When the active root or
plugins-dir scope changes in-process (e.g. via
meerschaum.config.environment.replace_env), that stale __path__
makes subsequent plugin imports re-discover plugins under the previous
scope's .internal/plugins directory. Call this whenever the plugins
scope changes so the next import rebuilds against the current
PLUGINS_RESOURCES_PATH.
946def reload_plugins(plugins: Optional[List[str]] = None, debug: bool = False) -> None: 947 """ 948 Reload plugins back into memory. 949 950 Parameters 951 ---------- 952 plugins: Optional[List[str]], default None 953 The plugins to reload. `None` will reload all plugins. 954 955 """ 956 global _synced_symlinks 957 unload_plugins(plugins, debug=debug) 958 _synced_symlinks = 0 959 sync_plugins_symlinks(debug=debug) 960 load_plugins(skip_if_loaded=False, debug=debug)
Reload plugins back into memory.
Parameters
- plugins (Optional[List[str]], default None):
The plugins to reload.
Nonewill reload all plugins.
963def get_plugins(*to_load, try_import: bool = True) -> Union[Tuple[Plugin], Plugin]: 964 """ 965 Return a list of `Plugin` objects. 966 967 Parameters 968 ---------- 969 to_load: 970 If specified, only load specific plugins. 971 Otherwise return all plugins. 972 973 try_import: bool, default True 974 If `True`, allow for plugins to be imported. 975 """ 976 import meerschaum.config.paths as paths 977 import os 978 sync_plugins_symlinks() 979 _plugins = [ 980 Plugin(name) 981 for name in ( 982 to_load or [ 983 ( 984 name if (paths.PLUGINS_RESOURCES_PATH / name).is_dir() 985 else name[:-3] 986 ) 987 for name in os.listdir(paths.PLUGINS_RESOURCES_PATH) 988 if name != '__init__.py' 989 ] 990 ) 991 ] 992 plugins = tuple(plugin for plugin in _plugins if plugin.is_installed(try_import=try_import)) 993 if len(to_load) == 1: 994 if len(plugins) == 0: 995 raise ValueError(f"Plugin '{to_load[0]}' is not installed.") 996 return plugins[0] 997 return plugins
Return a list of Plugin objects.
Parameters
- to_load:: If specified, only load specific plugins. Otherwise return all plugins.
- try_import (bool, default True):
If
True, allow for plugins to be imported.
1015def get_data_plugins() -> List[Plugin]: 1016 """ 1017 Only return the modules of plugins with either `fetch()` or `sync()` functions. 1018 """ 1019 import inspect 1020 plugins = get_plugins() 1021 1022 ### ponytail: cache on the installed plugins' names. 1023 ### Accessing `plugin.module` re-enters `import_plugins()` (venv activation + 1024 ### a nested `get_plugins()`) for every plugin, so an uncached call costs 1025 ### hundreds of milliseconds. The shell completer calls this on every keystroke 1026 ### (via `get_connector_labels()`), which is what made typing `-c` laggy. 1027 ### Installing or removing a plugin changes the key, so the cache self-invalidates; 1028 ### editing a plugin in place requires a restart to pick up a new `fetch()`/`sync()`. 1029 cache_key = tuple(plugin.name for plugin in plugins) 1030 if cache_key in _data_plugins_cache: 1031 return _data_plugins_cache[cache_key] 1032 1033 data_names = {'sync', 'fetch'} 1034 data_plugins = [] 1035 for plugin in plugins: 1036 for name, ob in inspect.getmembers(plugin.module): 1037 if not inspect.isfunction(ob): 1038 continue 1039 if name not in data_names: 1040 continue 1041 data_plugins.append(plugin) 1042 1043 _data_plugins_cache[cache_key] = data_plugins 1044 return data_plugins
Only return the modules of plugins with either fetch() or sync() functions.
1047def add_plugin_argument(*args, **kwargs) -> None: 1048 """ 1049 Add argparse arguments under the 'Plugins options' group. 1050 Takes the same parameters as the regular argparse `add_argument()` function. 1051 1052 Examples 1053 -------- 1054 >>> add_plugin_argument('--foo', type=int, help="This is my help text!") 1055 >>> 1056 """ 1057 from meerschaum._internal.arguments._parser import groups, _seen_plugin_args, parser 1058 from meerschaum.utils.warnings import warn 1059 _parent_plugin_name = _get_parent_plugin() 1060 title = f"Plugin '{_parent_plugin_name}' options" if _parent_plugin_name else 'Custom options' 1061 group_key = 'plugin_' + (_parent_plugin_name or '') 1062 if group_key not in groups: 1063 groups[group_key] = parser.add_argument_group( 1064 title = title, 1065 ) 1066 _seen_plugin_args[group_key] = set() 1067 try: 1068 if str(args) not in _seen_plugin_args[group_key]: 1069 groups[group_key].add_argument(*args, **kwargs) 1070 _seen_plugin_args[group_key].add(str(args)) 1071 except Exception as e: 1072 warn(e)
Add argparse arguments under the 'Plugins options' group.
Takes the same parameters as the regular argparse add_argument() function.
Examples
>>> add_plugin_argument('--foo', type=int, help="This is my help text!")
>>>
141def pre_sync_hook( 142 function: Callable[[Any], Any], 143) -> Callable[[Any], Any]: 144 """ 145 Register a function as a sync hook to be executed right before sync. 146 147 Parameters 148 ---------- 149 function: Callable[[Any], Any] 150 The function to execute right before a sync. 151 152 Returns 153 ------- 154 Another function (this is a decorator function). 155 156 Examples 157 -------- 158 >>> from meerschaum.plugins import pre_sync_hook 159 >>> 160 >>> @pre_sync_hook 161 ... def log_sync(pipe, **kwargs): 162 ... print(f"About to sync {pipe} with kwargs:\n{kwargs}.") 163 >>> 164 """ 165 with _locks['_pre_sync_hooks']: 166 plugin_name = _get_parent_plugin(function=function) 167 try: 168 if plugin_name not in _pre_sync_hooks: 169 _pre_sync_hooks[plugin_name] = [] 170 _pre_sync_hooks[plugin_name].append(function) 171 except Exception as e: 172 from meerschaum.utils.warnings import warn 173 warn(e) 174 return function
Register a function as a sync hook to be executed right before sync.
Parameters
----------
function: Callable[[Any], Any]
The function to execute right before a sync.
Returns
-------
Another function (this is a decorator function).
Examples
--------
>>> from meerschaum.plugins import pre_sync_hook
>>>
>>> @pre_sync_hook
... def log_sync(pipe, **kwargs):
... print(f"About to sync {pipe} with kwargs:
{kwargs}.")
>
177def post_sync_hook( 178 function: Callable[[Any], Any], 179) -> Callable[[Any], Any]: 180 """ 181 Register a function as a sync hook to be executed upon completion of a sync. 182 183 Parameters 184 ---------- 185 function: Callable[[Any], Any] 186 The function to execute upon completion of a sync. 187 188 Returns 189 ------- 190 Another function (this is a decorator function). 191 192 Examples 193 -------- 194 >>> from meerschaum.plugins import post_sync_hook 195 >>> from meerschaum.utils.misc import interval_str 196 >>> from datetime import timedelta 197 >>> 198 >>> @post_sync_hook 199 ... def log_sync(pipe, success_tuple, duration=None, **kwargs): 200 ... duration_delta = timedelta(seconds=duration) 201 ... duration_text = interval_str(duration_delta) 202 ... print(f"It took {duration_text} to sync {pipe}.") 203 >>> 204 """ 205 with _locks['_post_sync_hooks']: 206 try: 207 plugin_name = _get_parent_plugin(function=function) 208 if plugin_name not in _post_sync_hooks: 209 _post_sync_hooks[plugin_name] = [] 210 _post_sync_hooks[plugin_name].append(function) 211 except Exception as e: 212 from meerschaum.utils.warnings import warn 213 warn(e) 214 return function
Register a function as a sync hook to be executed upon completion of a sync.
Parameters
- function (Callable[[Any], Any]): The function to execute upon completion of a sync.
Returns
- Another function (this is a decorator function).
Examples
>>> from meerschaum.plugins import post_sync_hook
>>> from meerschaum.utils.misc import interval_str
>>> from datetime import timedelta
>>>
>>> @post_sync_hook
... def log_sync(pipe, success_tuple, duration=None, **kwargs):
... duration_delta = timedelta(seconds=duration)
... duration_text = interval_str(duration_delta)
... print(f"It took {duration_text} to sync {pipe}.")
>>>