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