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
class Plugin:
  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.

Plugin( name: str, version: Optional[str] = None, user_id: Optional[int] = None, required: Optional[List[str]] = None, attributes: Optional[Dict[str, Any]] = None, archive_path: Optional[pathlib.Path] = None, venv_path: Optional[pathlib.Path] = None, repo_connector: Optional[meerschaum.connectors.APIConnector] = None, repo: Union[meerschaum.connectors.APIConnector, str, NoneType] = None)
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
name
attributes
user_id
archive_path
venv_path
repo_connector
 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.

version
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.

module
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).

import_error: Optional[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.

requirements_file_path: Optional[pathlib.Path]
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.

def is_installed(self, **kw) -> bool:
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 bool indicating whether a plugin exists and is successfully imported.
def make_tar(self, debug: bool = False) -> pathlib.Path:
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.Path to the archive file's path.
def install( self, skip_deps: bool = False, force: bool = False, debug: bool = False) -> Tuple[bool, str]:
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 SuccessTuple of success (bool) and a message (str).
def remove_archive(self, debug: bool = False) -> Tuple[bool, 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.

def remove_venv(self, debug: bool = False) -> Tuple[bool, str]:
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.

def uninstall(self, debug: bool = False) -> Tuple[bool, str]:
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.

def setup( self, *args: str, debug: bool = False, **kw: Any) -> Union[Tuple[bool, str], bool]:
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}"

If exists, run the plugin's setup() function.

Parameters
  • *args (str): The positional arguments passed to the setup() function.
  • debug (bool, default False): Verbosity toggle.
  • **kw (Any): The keyword arguments passed to the setup() function.
Returns
  • A SuccessTuple or bool indicating success.
def get_dependencies(self, debug: bool = False) -> List[str]:
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).
def get_required_plugins(self, debug: bool = False) -> List[Plugin]:
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.

def get_required_packages(self, debug: bool = False) -> List[str]:
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).

def activate_venv( self, dependencies: bool = True, init_if_not_exists: bool = True, debug: bool = False, **kw) -> bool:
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.
def deactivate_venv(self, dependencies: bool = True, debug: bool = False, **kw) -> bool:
 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.
def install_dependencies(self, force: bool = False, debug: bool = False) -> bool:
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.
full_name: str
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.

def make_action( function: Optional[Callable[[Any], Any]] = None, shell: bool = False, activate: bool = True, deactivate: bool = True, debug: bool = False, daemon: bool = True, skip_if_loaded: bool = True, _plugin_name: Optional[str] = None) -> Callable[[Any], Any]:
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"
>>>
def api_plugin(function: Callable[[Any], Any]) -> Callable[[Any], Any]:
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!'}
>>>
def dash_plugin(function: Callable[[Any], Any]) -> Callable[[Any], Any]:
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.

def web_page( page: Union[str, NoneType, Callable[[Any], Any]] = None, login_required: bool = True, skip_navbar: bool = False, page_group: Optional[str] = None, dark_theme: bool = True, **kwargs) -> Any:
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's dbc_dark component theme to this page (the default — most plugins want this). Set to False to opt out: the dbc_dark class 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!")])
>>>
def import_plugins( *plugins_to_import: Union[str, List[str], NoneType], warn: bool = True) -> "Union['ModuleType', Tuple['ModuleType', None]]":
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 to None.
Returns
  • A module of list of modules, depening on the number of plugins provided.
def from_plugin_import(plugin_import_name: str, *attrs: str) -> Any:
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)
def invalidate_plugins_cache() -> None:
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.

def reload_plugins(plugins: Optional[List[str]] = None, debug: bool = False) -> None:
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. None will reload all plugins.
def get_plugins( *to_load, try_import: bool = True) -> Union[Tuple[Plugin], Plugin]:
 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.
def get_data_plugins() -> List[Plugin]:
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.

def add_plugin_argument(*args, **kwargs) -> None:
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!")
>>>
def pre_sync_hook(function: Callable[[Any], Any]) -> Callable[[Any], Any]:
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}.")

>

def post_sync_hook(function: Callable[[Any], Any]) -> Callable[[Any], Any]:
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}.")
>>>