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