meerschaum.plugins

Expose plugin management APIs from the meerschaum.plugins module.

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

Handle packaging of Meerschaum plugins.

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)
33    def __init__(
34        self,
35        name: str,
36        version: Optional[str] = None,
37        user_id: Optional[int] = None,
38        required: Optional[List[str]] = None,
39        attributes: Optional[Dict[str, Any]] = None,
40        archive_path: Optional[pathlib.Path] = None,
41        venv_path: Optional[pathlib.Path] = None,
42        repo_connector: Optional['mrsm.connectors.api.APIConnector'] = None,
43        repo: Union['mrsm.connectors.api.APIConnector', str, None] = None,
44    ):
45        import meerschaum.config.paths as paths
46        from meerschaum._internal.static import STATIC_CONFIG
47        sep = STATIC_CONFIG['plugins']['repo_separator']
48        _repo = None
49        if sep in name:
50            try:
51                name, _repo = name.split(sep)
52            except Exception as e:
53                error(f"Invalid plugin name: '{name}'")
54        self._repo_in_name = _repo
55
56        if attributes is None:
57            attributes = {}
58        self.name = name
59        self.attributes = attributes
60        self.user_id = user_id
61        self._version = version
62        if required:
63            self._required = required
64        self.archive_path = (
65            archive_path if archive_path is not None
66            else paths.PLUGINS_ARCHIVES_RESOURCES_PATH / f"{self.name}.tar.gz"
67        )
68        self.venv_path = (
69            venv_path if venv_path is not None
70            else paths.VIRTENV_RESOURCES_PATH / self.name
71        )
72        self._repo_connector = repo_connector
73        self._repo_keys = repo
name
attributes
user_id
archive_path
venv_path
repo_connector
76    @property
77    def repo_connector(self):
78        """
79        Return the repository connector for this plugin.
80        NOTE: This imports the `connectors` module, which imports certain plugin modules.
81        """
82        if self._repo_connector is None:
83            from meerschaum.connectors.parse import parse_repo_keys
84
85            repo_keys = self._repo_keys or self._repo_in_name
86            if self._repo_in_name and self._repo_keys and self._repo_keys != self._repo_in_name:
87                error(
88                    f"Received inconsistent repos: '{self._repo_in_name}' and '{self._repo_keys}'."
89                )
90            repo_connector = parse_repo_keys(repo_keys)
91            self._repo_connector = repo_connector
92        return self._repo_connector

Return the repository connector for this plugin. NOTE: This imports the connectors module, which imports certain plugin modules.

version
 95    @property
 96    def version(self):
 97        """
 98        Return the plugin's module version is defined (`__version__`) if it's defined.
 99        """
100        if self._version is None:
101            try:
102                self._version = self.module.__version__
103            except Exception as e:
104                self._version = None
105        return self._version

Return the plugin's module version is defined (__version__) if it's defined.

module
108    @property
109    def module(self):
110        """
111        Return the Python module of the underlying plugin.
112        """
113        if '_module' not in self.__dict__ or self.__dict__.get('_module', None) is None:
114            if self.__file__ is None:
115                return None
116
117            from meerschaum.plugins import import_plugins
118            self._module = import_plugins(str(self), warn=False)
119
120        return self._module

Return the Python module of the underlying plugin.

requirements_file_path: Optional[pathlib.Path]
148    @property
149    def requirements_file_path(self) -> Union[pathlib.Path, None]:
150        """
151        If a file named `requirements.txt` exists, return its path.
152        """
153        if self.__file__ is None:
154            return None
155        path = pathlib.Path(self.__file__).parent / 'requirements.txt'
156        if not path.exists():
157            return None
158        return path

If a file named requirements.txt exists, return its path.

def is_installed(self, **kw) -> bool:
161    def is_installed(self, **kw) -> bool:
162        """
163        Check whether a plugin is correctly installed.
164
165        Returns
166        -------
167        A `bool` indicating whether a plugin exists and is successfully imported.
168        """
169        return self.__file__ is not None

Check whether a plugin is correctly installed.

Returns
  • A bool indicating whether a plugin exists and is successfully imported.
def make_tar(self, debug: bool = False) -> pathlib.Path:
172    def make_tar(self, debug: bool = False) -> pathlib.Path:
173        """
174        Compress the plugin's source files into a `.tar.gz` archive and return the archive's path.
175
176        Parameters
177        ----------
178        debug: bool, default False
179            Verbosity toggle.
180
181        Returns
182        -------
183        A `pathlib.Path` to the archive file's path.
184
185        """
186        import tarfile, pathlib, subprocess, fnmatch
187        from meerschaum.utils.debug import dprint
188        from meerschaum.utils.packages import attempt_import
189        pathspec = attempt_import('pathspec', debug=debug)
190
191        if not self.__file__:
192            from meerschaum.utils.warnings import error
193            error(f"Could not find file for plugin '{self}'.")
194        if '__init__.py' in self.__file__ or os.path.isdir(self.__file__):
195            path = self.__file__.replace('__init__.py', '')
196            is_dir = True
197        else:
198            path = self.__file__
199            is_dir = False
200
201        old_cwd = os.getcwd()
202        real_parent_path = pathlib.Path(os.path.realpath(path)).parent
203        os.chdir(real_parent_path)
204
205        default_patterns_to_ignore = [
206            '.pyc',
207            '__pycache__/',
208            'eggs/',
209            '__pypackages__/',
210            '.git',
211        ]
212
213        def parse_gitignore() -> 'Set[str]':
214            gitignore_path = pathlib.Path(path) / '.gitignore'
215            if not gitignore_path.exists():
216                return set(default_patterns_to_ignore)
217            with open(gitignore_path, 'r', encoding='utf-8') as f:
218                gitignore_text = f.read()
219            return set(pathspec.PathSpec.from_lines(
220                pathspec.patterns.GitWildMatchPattern,
221                default_patterns_to_ignore + gitignore_text.splitlines()
222            ).match_tree(path))
223
224        patterns_to_ignore = parse_gitignore() if is_dir else set()
225
226        if debug:
227            dprint(f"Patterns to ignore:\n{patterns_to_ignore}")
228
229        with tarfile.open(self.archive_path, 'w:gz') as tarf:
230            if not is_dir:
231                tarf.add(f"{self.name}.py")
232            else:
233                for root, dirs, files in os.walk(self.name):
234                    for f in files:
235                        good_file = True
236                        fp = os.path.join(root, f)
237                        for pattern in patterns_to_ignore:
238                            if pattern in str(fp) or f.startswith('.'):
239                                good_file = False
240                                break
241                        if good_file:
242                            if debug:
243                                dprint(f"Adding '{fp}'...")
244                            tarf.add(fp)
245
246        ### clean up and change back to old directory
247        os.chdir(old_cwd)
248
249        ### change to 775 to avoid permissions issues with the API in a Docker container
250        self.archive_path.chmod(0o775)
251
252        if debug:
253            dprint(f"Created archive '{self.archive_path}'.")
254        return self.archive_path

Compress the plugin's source files into a .tar.gz archive and return the archive's path.

Parameters
  • debug (bool, default False): Verbosity toggle.
Returns
  • A pathlib.Path to the archive file's path.
def install( self, skip_deps: bool = False, force: bool = False, debug: bool = False) -> Tuple[bool, str]:
257    def install(
258        self,
259        skip_deps: bool = False,
260        force: bool = False,
261        debug: bool = False,
262    ) -> SuccessTuple:
263        """
264        Extract a plugin's tar archive to the plugins directory.
265        
266        This function checks if the plugin is already installed and if the version is equal or
267        greater than the existing installation.
268
269        Parameters
270        ----------
271        skip_deps: bool, default False
272            If `True`, do not install dependencies.
273
274        force: bool, default False
275            If `True`, continue with installation, even if required packages fail to install.
276
277        debug: bool, default False
278            Verbosity toggle.
279
280        Returns
281        -------
282        A `SuccessTuple` of success (bool) and a message (str).
283
284        """
285        if self.full_name in _ongoing_installations:
286            return True, f"Already installing plugin '{self}'."
287        _ongoing_installations.add(self.full_name)
288
289        import meerschaum.config.paths as paths
290        from meerschaum.utils.warnings import warn, error
291        if debug:
292            from meerschaum.utils.debug import dprint
293        import tarfile
294        import re
295        import ast
296        from meerschaum.plugins import sync_plugins_symlinks
297        from meerschaum.utils.packages import attempt_import, reload_meerschaum
298        from meerschaum.utils.venv import init_venv
299        from meerschaum.utils.misc import safely_extract_tar
300        old_cwd = os.getcwd()
301        old_version = ''
302        new_version = ''
303        temp_dir = paths.PLUGINS_TEMP_RESOURCES_PATH / self.name
304        temp_dir.mkdir(exist_ok=True)
305
306        if not self.archive_path.exists():
307            return False, f"Missing archive file for plugin '{self}'."
308        if self.version is not None:
309            old_version = self.version
310            if debug:
311                dprint(f"Found existing version '{old_version}' for plugin '{self}'.")
312
313        if debug:
314            dprint(f"Extracting '{self.archive_path}' to '{temp_dir}'...")
315
316        try:
317            with tarfile.open(self.archive_path, 'r:gz') as tarf:
318                safely_extract_tar(tarf, temp_dir)
319        except Exception as e:
320            warn(e)
321            return False, f"Failed to extract plugin '{self.name}'."
322
323        ### search for version information
324        files = os.listdir(temp_dir)
325        
326        if str(files[0]) == self.name:
327            is_dir = True
328        elif str(files[0]) == self.name + '.py':
329            is_dir = False
330        else:
331            error(f"Unknown format encountered for plugin '{self}'.")
332
333        fpath = temp_dir / files[0]
334        if is_dir:
335            fpath = fpath / '__init__.py'
336
337        init_venv(self.name, debug=debug)
338        with open(fpath, 'r', encoding='utf-8') as f:
339            init_lines = f.readlines()
340        new_version = None
341        for line in init_lines:
342            if '__version__' not in line:
343                continue
344            version_match = re.search(r'__version__(\s?)=', line.lstrip().rstrip())
345            if not version_match:
346                continue
347            new_version = ast.literal_eval(line.split('=')[1].lstrip().rstrip())
348            break
349        if not new_version:
350            warn(
351                f"No `__version__` defined for plugin '{self}'. "
352                + "Assuming new version...",
353                stack = False,
354            )
355
356        packaging_version = attempt_import('packaging.version')
357        try:
358            is_new_version = (not new_version and not old_version) or (
359                packaging_version.parse(old_version) < packaging_version.parse(new_version)
360            )
361            is_same_version = new_version and old_version and (
362                packaging_version.parse(old_version) == packaging_version.parse(new_version)
363            )
364        except Exception:
365            is_new_version, is_same_version = True, False
366
367        ### Determine where to permanently store the new plugin.
368        plugin_installation_dir_path = paths.PLUGINS_DIR_PATHS[0]
369        for path in paths.PLUGINS_DIR_PATHS:
370            if not path.exists():
371                warn(f"Plugins path does not exist: {path}", stack=False)
372                continue
373
374            files_in_plugins_dir = os.listdir(path)
375            if (
376                self.name in files_in_plugins_dir
377                or
378                (self.name + '.py') in files_in_plugins_dir
379            ):
380                plugin_installation_dir_path = path
381                break
382
383        success_msg = (
384            f"Successfully installed plugin '{self}'"
385            + ("\n    (skipped dependencies)" if skip_deps else "")
386            + "."
387        )
388        success, abort = None, None
389
390        if is_same_version and not force:
391            success, msg = True, (
392                f"Plugin '{self}' is up-to-date (version {old_version}).\n" +
393                "    Install again with `-f` or `--force` to reinstall."
394            )
395            abort = True
396        elif is_new_version or force:
397            for src_dir, dirs, files in os.walk(temp_dir):
398                if success is not None:
399                    break
400                dst_dir = str(src_dir).replace(str(temp_dir), str(plugin_installation_dir_path))
401                if not os.path.exists(dst_dir):
402                    os.mkdir(dst_dir)
403                for f in files:
404                    src_file = os.path.join(src_dir, f)
405                    dst_file = os.path.join(dst_dir, f)
406                    if os.path.exists(dst_file):
407                        os.remove(dst_file)
408
409                    if debug:
410                        dprint(f"Moving '{src_file}' to '{dst_dir}'...")
411                    try:
412                        shutil.move(src_file, dst_dir)
413                    except Exception:
414                        success, msg = False, (
415                            f"Failed to install plugin '{self}': " +
416                            f"Could not move file '{src_file}' to '{dst_dir}'"
417                        )
418                        print(msg)
419                        break
420            if success is None:
421                success, msg = True, success_msg
422        else:
423            success, msg = False, (
424                f"Your installed version of plugin '{self}' ({old_version}) is higher than "
425                + f"attempted version {new_version}."
426            )
427
428        shutil.rmtree(temp_dir)
429        os.chdir(old_cwd)
430
431        ### Reload the plugin's module.
432        sync_plugins_symlinks(debug=debug)
433        if '_module' in self.__dict__:
434            del self.__dict__['_module']
435        init_venv(venv=self.name, force=True, debug=debug)
436        reload_meerschaum(debug=debug)
437
438        ### Record the origin repository (only if explicitly known) so the update
439        ### checker knows where to look for newer versions. Locally-installed plugins
440        ### have no explicit repo and are intentionally skipped.
441        if success or abort:
442            _origin_repo_keys = None
443            if self._repo_connector is not None:
444                _origin_repo_keys = str(self._repo_connector)
445            elif self._repo_keys:
446                _origin_repo_keys = self._repo_keys
447            elif self._repo_in_name:
448                _origin_repo_keys = self._repo_in_name
449            if _origin_repo_keys:
450                try:
451                    from meerschaum.plugins._origins import write_plugin_origin
452                    write_plugin_origin(
453                        self.name,
454                        _origin_repo_keys,
455                        plugin_installation_dir_path,
456                        debug=debug,
457                    )
458                except Exception:
459                    pass
460
461        ### if we've already failed, return here
462        if not success or abort:
463            _ongoing_installations.remove(self.full_name)
464            return success, msg
465
466        ### attempt to install dependencies
467        dependencies_installed = skip_deps or self.install_dependencies(force=force, debug=debug)
468        if not dependencies_installed:
469            _ongoing_installations.remove(self.full_name)
470            return False, f"Failed to install dependencies for plugin '{self}'."
471
472        ### handling success tuple, bool, or other (typically None)
473        setup_tuple = self.setup(debug=debug)
474        if isinstance(setup_tuple, tuple):
475            if not setup_tuple[0]:
476                success, msg = setup_tuple
477        elif isinstance(setup_tuple, bool):
478            if not setup_tuple:
479                success, msg = False, (
480                    f"Failed to run post-install setup for plugin '{self}'." + '\n' +
481                    f"Check `setup()` in '{self.__file__}' for more information " +
482                    "(no error message provided)."
483                )
484            else:
485                success, msg = True, success_msg
486        elif setup_tuple is None:
487            success = True
488            msg = (
489                f"Post-install for plugin '{self}' returned None. " +
490                "Assuming plugin successfully installed."
491            )
492            warn(msg)
493        else:
494            success = False
495            msg = (
496                f"Post-install for plugin '{self}' returned unexpected value " +
497                f"of type '{type(setup_tuple)}': {setup_tuple}"
498            )
499
500        _ongoing_installations.remove(self.full_name)
501        _ = self.module
502        return success, msg

Extract a plugin's tar archive to the plugins directory.

This function checks if the plugin is already installed and if the version is equal or greater than the existing installation.

Parameters
  • skip_deps (bool, default False): If True, do not install dependencies.
  • force (bool, default False): If True, continue with installation, even if required packages fail to install.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A SuccessTuple of success (bool) and a message (str).
def remove_archive(self, debug: bool = False) -> Tuple[bool, str]:
505    def remove_archive(
506        self,        
507        debug: bool = False
508    ) -> SuccessTuple:
509        """Remove a plugin's archive file."""
510        if not self.archive_path.exists():
511            return True, f"Archive file for plugin '{self}' does not exist."
512        try:
513            self.archive_path.unlink()
514        except Exception as e:
515            return False, f"Failed to remove archive for plugin '{self}':\n{e}"
516        return True, "Success"

Remove a plugin's archive file.

def remove_venv(self, debug: bool = False) -> Tuple[bool, str]:
519    def remove_venv(
520        self,        
521        debug: bool = False
522    ) -> SuccessTuple:
523        """Remove a plugin's virtual environment."""
524        if not self.venv_path.exists():
525            return True, f"Virtual environment for plugin '{self}' does not exist."
526        try:
527            shutil.rmtree(self.venv_path)
528        except Exception as e:
529            return False, f"Failed to remove virtual environment for plugin '{self}':\n{e}"
530        return True, "Success"

Remove a plugin's virtual environment.

def uninstall(self, debug: bool = False) -> Tuple[bool, str]:
533    def uninstall(self, debug: bool = False) -> SuccessTuple:
534        """
535        Remove a plugin, its virtual environment, and archive file.
536        """
537        from meerschaum.utils.packages import reload_meerschaum
538        from meerschaum.plugins import sync_plugins_symlinks
539        from meerschaum.utils.warnings import warn, info
540        warnings_thrown_count: int = 0
541        max_warnings: int = 3
542
543        if not self.is_installed():
544            info(
545                f"Plugin '{self.name}' doesn't seem to be installed.\n    "
546                + "Checking for artifacts...",
547                stack = False,
548            )
549        else:
550            real_path = pathlib.Path(os.path.realpath(self.__file__))
551            try:
552                if real_path.name == '__init__.py':
553                    shutil.rmtree(real_path.parent)
554                else:
555                    real_path.unlink()
556            except Exception as e:
557                warn(f"Could not remove source files for plugin '{self.name}':\n{e}", stack=False)
558                warnings_thrown_count += 1
559            else:
560                info(f"Removed source files for plugin '{self.name}'.")
561
562        if self.venv_path.exists():
563            success, msg = self.remove_venv(debug=debug)
564            if not success:
565                warn(msg, stack=False)
566                warnings_thrown_count += 1
567            else:
568                info(f"Removed virtual environment from plugin '{self.name}'.")
569
570        success = warnings_thrown_count < max_warnings
571        try:
572            from meerschaum.plugins._origins import remove_plugin_origin
573            remove_plugin_origin(self.name, debug=debug)
574        except Exception:
575            pass
576        sync_plugins_symlinks(debug=debug)
577        self.deactivate_venv(force=True, debug=debug)
578        reload_meerschaum(debug=debug)
579        return success, (
580            f"Successfully uninstalled plugin '{self}'." if success
581            else f"Failed to uninstall plugin '{self}'."
582        )

Remove a plugin, its virtual environment, and archive file.

def setup( self, *args: str, debug: bool = False, **kw: Any) -> Union[Tuple[bool, str], bool]:
585    def setup(self, *args: str, debug: bool = False, **kw: Any) -> Union[SuccessTuple, bool]:
586        """
587        If exists, run the plugin's `setup()` function.
588
589        Parameters
590        ----------
591        *args: str
592            The positional arguments passed to the `setup()` function.
593            
594        debug: bool, default False
595            Verbosity toggle.
596
597        **kw: Any
598            The keyword arguments passed to the `setup()` function.
599
600        Returns
601        -------
602        A `SuccessTuple` or `bool` indicating success.
603
604        """
605        from meerschaum.utils.debug import dprint
606        import inspect
607        _setup = None
608        for name, fp in inspect.getmembers(self.module):
609            if name == 'setup' and inspect.isfunction(fp):
610                _setup = fp
611                break
612
613        ### assume success if no setup() is found (not necessary)
614        if _setup is None:
615            return True
616
617        sig = inspect.signature(_setup)
618        has_debug, has_kw = ('debug' in sig.parameters), False
619        for k, v in sig.parameters.items():
620            if '**' in str(v):
621                has_kw = True
622                break
623
624        _kw = {}
625        if has_kw:
626            _kw.update(kw)
627        if has_debug:
628            _kw['debug'] = debug
629
630        if debug:
631            dprint(f"Running setup for plugin '{self}'...")
632        try:
633            self.activate_venv(debug=debug)
634            return_tuple = _setup(*args, **_kw)
635            self.deactivate_venv(debug=debug)
636        except Exception as e:
637            return False, str(e)
638
639        if isinstance(return_tuple, tuple):
640            return return_tuple
641        if isinstance(return_tuple, bool):
642            return return_tuple, f"Setup for Plugin '{self.name}' did not return a message."
643        if return_tuple is None:
644            return False, f"Setup for Plugin '{self.name}' returned None."
645        return False, f"Unknown return value from setup for Plugin '{self.name}': {return_tuple}"

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]:
648    def get_dependencies(
649        self,
650        debug: bool = False,
651    ) -> List[str]:
652        """
653        If the Plugin has specified dependencies in a list called `required`, return the list.
654        
655        **NOTE:** Dependecies which start with `'plugin:'` are Meerschaum plugins, not pip packages.
656        Meerschaum plugins may also specify connector keys for a repo after `'@'`.
657
658        Parameters
659        ----------
660        debug: bool, default False
661            Verbosity toggle.
662
663        Returns
664        -------
665        A list of required packages and plugins (str).
666
667        """
668        if '_required' in self.__dict__:
669            return self._required
670
671        ### If the plugin has not yet been imported,
672        ### infer the dependencies from the source text.
673        ### This is not super robust, and it doesn't feel right
674        ### having multiple versions of the logic.
675        ### This is necessary when determining the activation order
676        ### without having import the module.
677        ### For consistency's sake, the module-less method does not cache the requirements.
678        if self.__dict__.get('_module', None) is None:
679            file_path = self.__file__
680            if file_path is None:
681                return []
682            with open(file_path, 'r', encoding='utf-8') as f:
683                text = f.read()
684
685            if 'required' not in text:
686                return []
687
688            ### This has some limitations:
689            ### It relies on `required` being manually declared.
690            ### We lose the ability to dynamically alter the `required` list,
691            ### which is why we've kept the module-reliant method below.
692            import ast, re
693            ### NOTE: This technically would break 
694            ### if `required` was the very first line of the file.
695            req_start_match = re.search(r'\nrequired(:\s*)?.*=', text)
696            if not req_start_match:
697                return []
698            req_start = req_start_match.start()
699            equals_sign = req_start + text[req_start:].find('=')
700
701            ### Dependencies may have brackets within the strings, so push back the index.
702            first_opening_brace = equals_sign + 1 + text[equals_sign:].find('[')
703            if first_opening_brace == -1:
704                return []
705
706            next_closing_brace = equals_sign + 1 + text[equals_sign:].find(']')
707            if next_closing_brace == -1:
708                return []
709
710            start_ix = first_opening_brace + 1
711            end_ix = next_closing_brace
712
713            num_braces = 0
714            while True:
715                if '[' not in text[start_ix:end_ix]:
716                    break
717                num_braces += 1
718                start_ix = end_ix
719                end_ix += text[end_ix + 1:].find(']') + 1
720
721            req_end = end_ix + 1
722            req_text = (
723                text[(first_opening_brace-1):req_end]
724                .lstrip()
725                .replace('=', '', 1)
726                .lstrip()
727                .rstrip()
728            )
729            try:
730                required = ast.literal_eval(req_text)
731            except Exception as e:
732                warn(
733                    f"Unable to determine requirements for plugin '{self.name}' "
734                    + "without importing the module.\n"
735                    + "    This may be due to dynamically setting the global `required` list.\n"
736                    + f"    {e}"
737                )
738                return []
739            return required
740
741        import inspect
742        self.activate_venv(dependencies=False, debug=debug)
743        required = []
744        for name, val in inspect.getmembers(self.module):
745            if name == 'required':
746                required = val
747                break
748        self._required = required
749        self.deactivate_venv(dependencies=False, debug=debug)
750        return required

If the Plugin has specified dependencies in a list called required, return the list.

NOTE: Dependecies which start with 'plugin:' are Meerschaum plugins, not pip packages. Meerschaum plugins may also specify connector keys for a repo after '@'.

Parameters
  • debug (bool, default False): Verbosity toggle.
Returns
  • A list of required packages and plugins (str).
def get_required_plugins(self, debug: bool = False) -> List[Plugin]:
753    def get_required_plugins(self, debug: bool=False) -> List[mrsm.plugins.Plugin]:
754        """
755        Return a list of required Plugin objects.
756        """
757        from meerschaum.utils.warnings import warn
758        from meerschaum.config import get_config
759        from meerschaum._internal.static import STATIC_CONFIG
760        from meerschaum.connectors.parse import is_valid_connector_keys
761        plugins = []
762        _deps = self.get_dependencies(debug=debug)
763        sep = STATIC_CONFIG['plugins']['repo_separator']
764        plugin_names = [
765            _d[len('plugin:'):] for _d in _deps
766            if _d.startswith('plugin:') and len(_d) > len('plugin:')
767        ]
768        default_repo_keys = get_config('meerschaum', 'repository')
769        skipped_repo_keys = set()
770
771        for _plugin_name in plugin_names:
772            if sep in _plugin_name:
773                try:
774                    _plugin_name, _repo_keys = _plugin_name.split(sep)
775                except Exception:
776                    _repo_keys = default_repo_keys
777                    warn(
778                        f"Invalid repo keys for required plugin '{_plugin_name}'.\n    "
779                        + f"Will try to use '{_repo_keys}' instead.",
780                        stack = False,
781                    )
782            else:
783                _repo_keys = default_repo_keys
784
785            if _repo_keys in skipped_repo_keys:
786                continue
787
788            if not is_valid_connector_keys(_repo_keys):
789                warn(
790                    f"Invalid connector '{_repo_keys}'.\n"
791                    f"    Skipping required plugins from repository '{_repo_keys}'",
792                    stack=False,
793                )
794                continue
795
796            plugins.append(Plugin(_plugin_name, repo=_repo_keys))
797
798        return plugins

Return a list of required Plugin objects.

def get_required_packages(self, debug: bool = False) -> List[str]:
801    def get_required_packages(self, debug: bool=False) -> List[str]:
802        """
803        Return the required package names (excluding plugins).
804        """
805        _deps = self.get_dependencies(debug=debug)
806        return [_d for _d in _deps if not _d.startswith('plugin:')]

Return the required package names (excluding plugins).

def activate_venv( self, dependencies: bool = True, init_if_not_exists: bool = True, debug: bool = False, **kw) -> bool:
809    def activate_venv(
810        self,
811        dependencies: bool = True,
812        init_if_not_exists: bool = True,
813        debug: bool = False,
814        **kw
815    ) -> bool:
816        """
817        Activate the virtual environments for the plugin and its dependencies.
818
819        Parameters
820        ----------
821        dependencies: bool, default True
822            If `True`, activate the virtual environments for required plugins.
823
824        Returns
825        -------
826        A bool indicating success.
827        """
828        import meerschaum.config.paths as paths
829        from meerschaum.utils.venv import venv_target_path
830        from meerschaum.utils.packages import activate_venv
831        from meerschaum.utils.misc import make_symlink, is_symlink
832
833        if dependencies:
834            for plugin in self.get_required_plugins(debug=debug):
835                plugin.activate_venv(debug=debug, init_if_not_exists=init_if_not_exists, **kw)
836
837        vtp = venv_target_path(self.name, debug=debug, allow_nonexistent=True)
838        venv_meerschaum_path = vtp / 'meerschaum'
839
840        try:
841            success, msg = True, "Success"
842            if is_symlink(venv_meerschaum_path):
843                if pathlib.Path(os.path.realpath(venv_meerschaum_path)) != paths.PACKAGE_ROOT_PATH:
844                    venv_meerschaum_path.unlink()
845                    success, msg = make_symlink(venv_meerschaum_path, paths.PACKAGE_ROOT_PATH)
846        except Exception as e:
847            success, msg = False, str(e)
848        if not success:
849            warn(
850                f"Unable to create symlink {venv_meerschaum_path} to {paths.PACKAGE_ROOT_PATH}:\n"
851                f"{msg}"
852            )
853
854        return activate_venv(self.name, init_if_not_exists=init_if_not_exists, debug=debug, **kw)

Activate the virtual environments for the plugin and its dependencies.

Parameters
  • dependencies (bool, default True): If True, activate the virtual environments for required plugins.
Returns
  • A bool indicating success.
def deactivate_venv(self, dependencies: bool = True, debug: bool = False, **kw) -> bool:
857    def deactivate_venv(self, dependencies: bool=True, debug: bool = False, **kw) -> bool:
858        """
859        Deactivate the virtual environments for the plugin and its dependencies.
860
861        Parameters
862        ----------
863        dependencies: bool, default True
864            If `True`, deactivate the virtual environments for required plugins.
865
866        Returns
867        -------
868        A bool indicating success.
869        """
870        from meerschaum.utils.packages import deactivate_venv
871        success = deactivate_venv(self.name, debug=debug, **kw)
872        if dependencies:
873            for plugin in self.get_required_plugins(debug=debug):
874                plugin.deactivate_venv(debug=debug, **kw)
875        return success

Deactivate the virtual environments for the plugin and its dependencies.

Parameters
  • dependencies (bool, default True): If True, deactivate the virtual environments for required plugins.
Returns
  • A bool indicating success.
def install_dependencies(self, force: bool = False, debug: bool = False) -> bool:
878    def install_dependencies(
879        self,
880        force: bool = False,
881        debug: bool = False,
882    ) -> bool:
883        """
884        If specified, install dependencies.
885        
886        **NOTE:** Dependencies that start with `'plugin:'` will be installed as
887        Meerschaum plugins from the same repository as this Plugin.
888        To install from a different repository, add the repo keys after `'@'`
889        (e.g. `'plugin:foo@api:bar'`).
890
891        Parameters
892        ----------
893        force: bool, default False
894            If `True`, continue with the installation, even if some
895            required packages fail to install.
896
897        debug: bool, default False
898            Verbosity toggle.
899
900        Returns
901        -------
902        A bool indicating success.
903        """
904        from meerschaum.utils.packages import pip_install, venv_contains_package
905        from meerschaum.utils.warnings import warn, info
906        _deps = self.get_dependencies(debug=debug)
907        if not _deps and self.requirements_file_path is None:
908            return True
909
910        plugins = self.get_required_plugins(debug=debug)
911        for _plugin in plugins:
912            if _plugin.name == self.name:
913                warn(f"Plugin '{self.name}' cannot depend on itself! Skipping...", stack=False)
914                continue
915            _success, _msg = _plugin.repo_connector.install_plugin(
916                _plugin.name, debug=debug, force=force
917            )
918            if not _success:
919                warn(
920                    f"Failed to install required plugin '{_plugin}' from '{_plugin.repo_connector}'"
921                    + f" for plugin '{self.name}':\n" + _msg,
922                    stack = False,
923                )
924                if not force:
925                    warn(
926                        "Try installing with the `--force` flag to continue anyway.",
927                        stack = False,
928                    )
929                    return False
930                info(
931                    "Continuing with installation despite the failure "
932                    + "(careful, things might be broken!)...",
933                    icon = False
934                )
935
936
937        ### First step: parse `requirements.txt` if it exists.
938        if self.requirements_file_path is not None:
939            if not pip_install(
940                requirements_file_path=self.requirements_file_path,
941                venv=self.name, debug=debug
942            ):
943                warn(
944                    f"Failed to resolve 'requirements.txt' for plugin '{self.name}'.",
945                    stack = False,
946                )
947                if not force:
948                    warn(
949                        "Try installing with `--force` to continue anyway.",
950                        stack = False,
951                    )
952                    return False
953                info(
954                    "Continuing with installation despite the failure "
955                    + "(careful, things might be broken!)...",
956                    icon = False
957                )
958
959
960        ### Don't reinstall packages that are already included in required plugins.
961        packages = []
962        _packages = self.get_required_packages(debug=debug)
963        accounted_for_packages = set()
964        for package_name in _packages:
965            for plugin in plugins:
966                if venv_contains_package(package_name, plugin.name):
967                    accounted_for_packages.add(package_name)
968                    break
969        packages = [pkg for pkg in _packages if pkg not in accounted_for_packages]
970
971        ### Attempt pip packages installation.
972        if packages:
973            for package in packages:
974                if not pip_install(package, venv=self.name, debug=debug):
975                    warn(
976                        f"Failed to install required package '{package}'"
977                        + f" for plugin '{self.name}'.",
978                        stack = False,
979                    )
980                    if not force:
981                        warn(
982                            "Try installing with `--force` to continue anyway.",
983                            stack = False,
984                        )
985                        return False
986                    info(
987                        "Continuing with installation despite the failure "
988                        + "(careful, things might be broken!)...",
989                        icon = False
990                    )
991        return True

If specified, install dependencies.

NOTE: Dependencies that start with 'plugin:' will be installed as Meerschaum plugins from the same repository as this Plugin. To install from a different repository, add the repo keys after '@' (e.g. 'plugin:foo@api:bar').

Parameters
  • force (bool, default False): If True, continue with the installation, even if some required packages fail to install.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A bool indicating success.
full_name: str
 994    @property
 995    def full_name(self) -> str:
 996        """
 997        Include the repo keys with the plugin's name.
 998        """
 999        from meerschaum._internal.static import STATIC_CONFIG
1000        sep = STATIC_CONFIG['plugins']['repo_separator']
1001        return self.name + sep + str(self.repo_connector)

Include the repo keys with the plugin's name.

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]:
 61def make_action(
 62    function: Optional[Callable[[Any], Any]] = None,
 63    shell: bool = False,
 64    activate: bool = True,
 65    deactivate: bool = True,
 66    debug: bool = False,
 67    daemon: bool = True,
 68    skip_if_loaded: bool = True,
 69    _plugin_name: Optional[str] = None,
 70) -> Callable[[Any], Any]:
 71    """
 72    Make a function a Meerschaum action. Useful for plugins that are adding multiple actions.
 73    
 74    Parameters
 75    ----------
 76    function: Callable[[Any], Any]
 77        The function to become a Meerschaum action. Must accept all keyword arguments.
 78        
 79    shell: bool, default False
 80        Not used.
 81        
 82    Returns
 83    -------
 84    Another function (this is a decorator function).
 85
 86    Examples
 87    --------
 88    >>> from meerschaum.plugins import make_action
 89    >>>
 90    >>> @make_action
 91    ... def my_action(**kw):
 92    ...     print('foo')
 93    ...     return True, "Success"
 94    >>>
 95    """
 96    def _decorator(func: Callable[[Any], Any]) -> Callable[[Any], Any]:
 97        from meerschaum.actions import actions, _custom_actions_plugins, _plugins_actions
 98        if skip_if_loaded and func.__name__ in actions:
 99            return func
100
101        import meerschaum.config.paths as paths
102        plugin_name = (
103            func.__name__.split(
104                f"{paths.PLUGINS_RESOURCES_PATH.stem}.",
105                maxsplit=1,
106            )[-1].split('.')[0]
107        )
108        plugin = Plugin(plugin_name) if plugin_name else None
109
110        if debug:
111            from meerschaum.utils.debug import dprint
112            dprint(
113                f"Adding action '{func.__name__}' from plugin "
114                f"'{plugin}'..."
115            )
116
117        actions[func.__name__] = func
118        _custom_actions_plugins[func.__name__] = plugin_name
119        if plugin_name not in _plugins_actions:
120            _plugins_actions[plugin_name] = []
121        _plugins_actions[plugin_name].append(func.__name__)
122        if not daemon:
123            _actions_daemon_enabled[func.__name__] = False
124        return func
125
126    if function is None:
127        return _decorator
128    return _decorator(function)

Make a function a Meerschaum action. Useful for plugins that are adding multiple actions.

Parameters
  • function (Callable[[Any], Any]): The function to become a Meerschaum action. Must accept all keyword arguments.
  • shell (bool, default False): Not used.
Returns
  • Another function (this is a decorator function).
Examples
>>> from meerschaum.plugins import make_action
>>>
>>> @make_action
... def my_action(**kw):
...     print('foo')
...     return True, "Success"
>>>
def api_plugin(function: Callable[[Any], Any]) -> Callable[[Any], Any]:
311def api_plugin(function: Callable[[Any], Any]) -> Callable[[Any], Any]:
312    """
313    Execute the function when initializing the Meerschaum API module.
314    Useful for lazy-loading heavy plugins only when the API is started,
315    such as when editing the `meerschaum.api.app` FastAPI app.
316    
317    The FastAPI app will be passed as the only parameter.
318    
319    Examples
320    --------
321    >>> from meerschaum.plugins import api_plugin
322    >>>
323    >>> @api_plugin
324    >>> def initialize_plugin(app):
325    ...     @app.get('/my/new/path')
326    ...     def new_path():
327    ...         return {'message': 'It works!'}
328    >>>
329    """
330    with _locks['_api_plugins']:
331        try:
332            if function.__module__ not in _api_plugins:
333                _api_plugins[function.__module__] = []
334            _api_plugins[function.__module__].append(function)
335        except Exception as e:
336            from meerschaum.utils.warnings import warn
337            warn(e)
338    return function

Execute the function when initializing the Meerschaum API module. Useful for lazy-loading heavy plugins only when the API is started, such as when editing the meerschaum.api.app FastAPI app.

The FastAPI app will be passed as the only parameter.

Examples
>>> from meerschaum.plugins import api_plugin
>>>
>>> @api_plugin
>>> def initialize_plugin(app):
...     @app.get('/my/new/path')
...     def new_path():
...         return {'message': 'It works!'}
>>>
def dash_plugin(function: Callable[[Any], Any]) -> Callable[[Any], Any]:
295def dash_plugin(function: Callable[[Any], Any]) -> Callable[[Any], Any]:
296    """
297    Execute the function when starting the Dash application.
298    """
299    with _locks['_dash_plugins']:
300        plugin_name = _get_parent_plugin()
301        try:
302            if plugin_name not in _dash_plugins:
303                _dash_plugins[plugin_name] = []
304            _dash_plugins[plugin_name].append(function)
305        except Exception as e:
306            from meerschaum.utils.warnings import warn
307            warn(e)
308    return function

Execute the function when starting the Dash application.

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:
209def web_page(
210    page: Union[str, None, Callable[[Any], Any]] = None,
211    login_required: bool = True,
212    skip_navbar: bool = False,
213    page_group: Optional[str] = None,
214    dark_theme: bool = True,
215    **kwargs
216) -> Any:
217    """
218    Quickly add pages to the dash application.
219
220    Parameters
221    ----------
222    dark_theme: bool, default True
223        If `True`, apply the Web Console's `dbc_dark` component theme to this page
224        (the default — most plugins want this). Set to `False` to opt out: the
225        `dbc_dark` class is removed from `<body>` while this page is active, so a
226        plugin's own styling applies without competing with the theme's overrides.
227        Note the global base Bootstrap theme (dark background) still applies, so an
228        opted-out page should paint its own background.
229
230    Examples
231    --------
232    >>> import meerschaum as mrsm
233    >>> from meerschaum.plugins import web_page
234    >>> html = mrsm.attempt_import('dash.html')
235    >>>
236    >>> @web_page('foo/bar', login_required=False)
237    >>> def foo_bar():
238    ...     return html.Div([html.H1("Hello, World!")])
239    >>>
240    """
241    page_str = None
242
243    def _decorator(_func: Callable[[Any], Any]) -> Callable[[Any], Any]:
244        nonlocal page_str, page_group
245
246        @functools.wraps(_func)
247        def wrapper(*_args, **_kwargs):
248            return _func(*_args, **_kwargs)
249
250        if page_str is None:
251            page_str = _func.__name__
252
253        page_str = page_str.lstrip('/').rstrip('/').strip()
254        if not page_str.startswith('dash'):
255            page_str = f'/dash/{page_str}'
256        page_key = (
257            ' '.join(
258                [
259                    word.capitalize()
260                    for word in (
261                        page_str.replace('/dash', '').lstrip('/').rstrip('/').strip()
262                        .replace('-', ' ').replace('_', ' ').split(' ')
263                    )
264                ]
265            )
266        )
267 
268        plugin_name = _get_parent_plugin()
269        page_group = page_group or plugin_name
270        if page_group not in _plugin_endpoints_to_pages:
271            _plugin_endpoints_to_pages[page_group] = {}
272        _plugin_endpoints_to_pages[page_group][page_str] = {
273            'function': _func,
274            'login_required': login_required,
275            'skip_navbar': skip_navbar,
276            'page_key': page_key,
277            'dark_theme': dark_theme,
278        }
279        if plugin_name not in _plugins_web_pages:
280            _plugins_web_pages[plugin_name] = []
281        _plugins_web_pages[plugin_name].append(_func)
282        return wrapper
283
284    if callable(page):
285        decorator_to_return = _decorator(page)
286        page_str = page.__name__
287    else:
288        decorator_to_return = _decorator
289        page_str = page
290
291    return decorator_to_return

Quickly add pages to the dash application.

Parameters
  • dark_theme (bool, default True): If True, apply the Web Console'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]]":
541def import_plugins(
542    *plugins_to_import: Union[str, List[str], None],
543    warn: bool = True,
544) -> Union[
545    'ModuleType', Tuple['ModuleType', None]
546]:
547    """
548    Import the Meerschaum plugins directory.
549
550    Parameters
551    ----------
552    plugins_to_import: Union[str, List[str], None]
553        If provided, only import the specified plugins.
554        Otherwise import the entire plugins module. May be a string, list, or `None`.
555        Defaults to `None`.
556
557    Returns
558    -------
559    A module of list of modules, depening on the number of plugins provided.
560
561    """
562    import sys
563    import importlib
564    import meerschaum.config.paths as paths
565    from meerschaum.utils.misc import flatten_list
566    from meerschaum.utils.venv import is_venv_active, activate_venv, deactivate_venv, Venv
567    from meerschaum.utils.warnings import warn as _warn
568    plugins_to_import = list(plugins_to_import)
569    prepended_sys_path = False
570    with _locks['sys.path']:
571
572        ### Since plugins may depend on other plugins,
573        ### we need to activate the virtual environments for library plugins.
574        ### This logic exists in `Plugin.activate_venv()`,
575        ### but that code requires the plugin's module to already be imported.
576        ### It's not a guarantee of correct activation order,
577        ### e.g. if a library plugin pins a specific package and another 
578        plugins_names = get_plugins_names()
579        already_active_venvs = [is_venv_active(plugin_name) for plugin_name in plugins_names]
580
581        if not sys.path or sys.path[0] != str(paths.PLUGINS_RESOURCES_PATH.parent):
582            prepended_sys_path = True
583            sys.path.insert(0, str(paths.PLUGINS_RESOURCES_PATH.parent))
584
585        if not plugins_to_import:
586            for plugin_name in plugins_names:
587                activate_venv(plugin_name)
588            try:
589                imported_plugins = importlib.import_module(paths.PLUGINS_RESOURCES_PATH.stem)
590            except ImportError as e:
591                _warn(f"Failed to import the plugins module:\n    {e}")
592                import traceback
593                traceback.print_exc()
594                imported_plugins = None
595            for plugin_name in plugins_names:
596                if plugin_name in already_active_venvs:
597                    continue
598                deactivate_venv(plugin_name)
599
600        else:
601            imported_plugins = []
602            for plugin_name in flatten_list(plugins_to_import):
603                plugin = Plugin(plugin_name)
604                try:
605                    with Venv(plugin, init_if_not_exists=False):
606                        imported_plugins.append(
607                            importlib.import_module(
608                                f'{paths.PLUGINS_RESOURCES_PATH.stem}.{plugin_name}'
609                            )
610                        )
611                except Exception as e:
612                    _warn(
613                        f"Failed to import plugin '{plugin_name}':\n    "
614                        + f"{e}\n\nHere's a stacktrace:",
615                        stack = False,
616                    )
617                    from meerschaum.utils.formatting import get_console
618                    get_console().print_exception(
619                        suppress = [
620                            'meerschaum/plugins/__init__.py',
621                            importlib,
622                            importlib._bootstrap,
623                        ]
624                    )
625                    imported_plugins.append(None)
626
627        if imported_plugins is None and warn:
628            _warn("Failed to import plugins.", stacklevel=3)
629
630        if prepended_sys_path and str(paths.PLUGINS_RESOURCES_PATH.parent) in sys.path:
631            sys.path.remove(str(paths.PLUGINS_RESOURCES_PATH.parent))
632
633    if isinstance(imported_plugins, list):
634        return (imported_plugins[0] if len(imported_plugins) == 1 else tuple(imported_plugins))
635    return imported_plugins

Import the Meerschaum plugins directory.

Parameters
  • plugins_to_import (Union[str, List[str], None]): If provided, only import the specified plugins. Otherwise import the entire plugins module. May be a string, list, or None. Defaults 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:
638def from_plugin_import(plugin_import_name: str, *attrs: str) -> Any:
639    """
640    Emulate the `from module import x` behavior.
641
642    Parameters
643    ----------
644    plugin_import_name: str
645        The import name of the plugin's module.
646        Separate submodules with '.' (e.g. 'compose.utils.pipes')
647
648    attrs: str
649        Names of the attributes to return.
650
651    Returns
652    -------
653    Objects from a plugin's submodule.
654    If multiple objects are provided, return a tuple.
655
656    Examples
657    --------
658    >>> init = from_plugin_import('compose.utils', 'init')
659    >>> with mrsm.Venv('compose'):
660    ...     cf = init()
661    >>> build_parent_pipe, get_defined_pipes = from_plugin_import(
662    ...     'compose.utils.pipes',
663    ...     'build_parent_pipe',
664    ...     'get_defined_pipes',
665    ... )
666    >>> parent_pipe = build_parent_pipe(cf)
667    >>> defined_pipes = get_defined_pipes(cf)
668    """
669    import importlib
670    import meerschaum.config.paths as paths
671    from meerschaum.utils.warnings import warn as _warn
672    if plugin_import_name.startswith('plugins.'):
673        plugin_import_name = plugin_import_name[len('plugins.'):]
674    plugin_import_parts = plugin_import_name.split('.')
675    plugin_root_name = plugin_import_parts[0]
676    plugin = mrsm.Plugin(plugin_root_name)
677
678    submodule_import_name = '.'.join(
679        [paths.PLUGINS_RESOURCES_PATH.stem]
680        + plugin_import_parts
681    )
682    if len(attrs) == 0:
683        raise ValueError(f"Provide which attributes to return from '{submodule_import_name}'.")
684
685    attrs_to_return = []
686    with mrsm.Venv(plugin):
687        if plugin.module is None:
688            raise ImportError(f"Unable to import plugin '{plugin}'.")
689
690        try:
691            submodule = importlib.import_module(submodule_import_name)
692        except ImportError as e:
693            _warn(
694                f"Failed to import plugin '{submodule_import_name}':\n    "
695                + f"{e}\n\nHere's a stacktrace:",
696                stack=False,
697            )
698            from meerschaum.utils.formatting import get_console
699            get_console().print_exception(
700                suppress=[
701                    'meerschaum/plugins/__init__.py',
702                    importlib,
703                    importlib._bootstrap,
704                ]
705            )
706            return None
707
708        for attr in attrs:
709            try:
710                attrs_to_return.append(getattr(submodule, attr))
711            except Exception:
712                _warn(f"Failed to access '{attr}' from '{submodule_import_name}'.")
713                attrs_to_return.append(None)
714        
715        if len(attrs) == 1:
716            return attrs_to_return[0]
717
718        return tuple(attrs_to_return)

Emulate the from module import x behavior.

Parameters
  • plugin_import_name (str): The import name of the plugin's module. Separate submodules with '.' (e.g. 'compose.utils.pipes')
  • attrs (str): Names of the attributes to return.
Returns
  • Objects from a plugin's submodule.
  • If multiple objects are provided, return a tuple.
Examples
>>> init = from_plugin_import('compose.utils', 'init')
>>> with mrsm.Venv('compose'):
...     cf = init()
>>> build_parent_pipe, get_defined_pipes = from_plugin_import(
...     'compose.utils.pipes',
...     'build_parent_pipe',
...     'get_defined_pipes',
... )
>>> parent_pipe = build_parent_pipe(cf)
>>> defined_pipes = get_defined_pipes(cf)
def invalidate_plugins_cache() -> None:
929def invalidate_plugins_cache() -> None:
930    """
931    Pop the cached `plugins` package and its submodules from `sys.modules`
932    and reset the loaded-plugins state.
933
934    The `plugins` package caches its `__path__` (the resolved
935    `PLUGINS_RESOURCES_PATH`) at import time. When the active root or
936    plugins-dir scope changes in-process (e.g. via
937    `meerschaum.config.environment.replace_env`), that stale `__path__`
938    makes subsequent plugin imports re-discover plugins under the previous
939    scope's `.internal/plugins` directory. Call this whenever the plugins
940    scope changes so the next import rebuilds against the current
941    `PLUGINS_RESOURCES_PATH`.
942    """
943    global _loaded_plugins, _synced_symlinks
944    import sys
945    import meerschaum.config.paths as paths
946
947    plugins_stem = paths.PLUGINS_RESOURCES_PATH.stem
948    module_prefix = plugins_stem + '.'
949    for mod_name in [
950        mod_name
951        for mod_name in sys.modules
952        if mod_name == plugins_stem or mod_name.startswith(module_prefix)
953    ]:
954        _ = sys.modules.pop(mod_name, None)
955
956    _loaded_plugins = False
957    ### Also reset the symlinks counter — it's per-scope state, and leaving it
958    ### >1 makes `sync_plugins_symlinks` skip creating the new scope's
959    ### `.internal/plugins` (so `plugins` imports as an empty namespace package).
960    _synced_symlinks = 0

Pop the cached plugins package and its submodules from sys.modules and reset the loaded-plugins state.

The plugins package caches its __path__ (the resolved PLUGINS_RESOURCES_PATH) at import time. When the active root or plugins-dir scope changes in-process (e.g. via meerschaum.config.environment.replace_env), that stale __path__ makes subsequent plugin imports re-discover plugins under the previous scope's .internal/plugins directory. Call this whenever the plugins scope changes so the next import rebuilds against the current PLUGINS_RESOURCES_PATH.

def reload_plugins(plugins: Optional[List[str]] = None, debug: bool = False) -> None:
963def reload_plugins(plugins: Optional[List[str]] = None, debug: bool = False) -> None:
964    """
965    Reload plugins back into memory.
966
967    Parameters
968    ----------
969    plugins: Optional[List[str]], default None
970        The plugins to reload. `None` will reload all plugins.
971
972    """
973    global _synced_symlinks
974    unload_plugins(plugins, debug=debug)
975    _synced_symlinks = 0
976    sync_plugins_symlinks(debug=debug)
977    load_plugins(skip_if_loaded=False, debug=debug)

Reload plugins back into memory.

Parameters
  • plugins (Optional[List[str]], default None): The plugins to reload. None will reload all plugins.
def get_plugins( *to_load, try_import: bool = True) -> Union[Tuple[Plugin], Plugin]:
 980def get_plugins(*to_load, try_import: bool = True) -> Union[Tuple[Plugin], Plugin]:
 981    """
 982    Return a list of `Plugin` objects.
 983
 984    Parameters
 985    ----------
 986    to_load:
 987        If specified, only load specific plugins.
 988        Otherwise return all plugins.
 989
 990    try_import: bool, default True
 991        If `True`, allow for plugins to be imported.
 992    """
 993    import meerschaum.config.paths as paths
 994    import os
 995    sync_plugins_symlinks()
 996    _plugins = [
 997        Plugin(name)
 998        for name in (
 999            to_load or [
1000                (
1001                    name if (paths.PLUGINS_RESOURCES_PATH / name).is_dir()
1002                    else name[:-3]
1003                )
1004                for name in os.listdir(paths.PLUGINS_RESOURCES_PATH)
1005                if name != '__init__.py'
1006            ]
1007        )
1008    ]
1009    plugins = tuple(plugin for plugin in _plugins if plugin.is_installed(try_import=try_import))
1010    if len(to_load) == 1:
1011        if len(plugins) == 0:
1012            raise ValueError(f"Plugin '{to_load[0]}' is not installed.")
1013        return plugins[0]
1014    return plugins

Return a list of Plugin objects.

Parameters
  • to_load:: If specified, only load specific plugins. Otherwise return all plugins.
  • try_import (bool, default True): If True, allow for plugins to be imported.
def get_data_plugins() -> List[Plugin]:
1031def get_data_plugins() -> List[Plugin]:
1032    """
1033    Only return the modules of plugins with either `fetch()` or `sync()` functions.
1034    """
1035    import inspect
1036    plugins = get_plugins()
1037    data_names = {'sync', 'fetch'}
1038    data_plugins = []
1039    for plugin in plugins:
1040        for name, ob in inspect.getmembers(plugin.module):
1041            if not inspect.isfunction(ob):
1042                continue
1043            if name not in data_names:
1044                continue
1045            data_plugins.append(plugin)
1046    return data_plugins

Only return the modules of plugins with either fetch() or sync() functions.

def add_plugin_argument(*args, **kwargs) -> None:
1049def add_plugin_argument(*args, **kwargs) -> None:
1050    """
1051    Add argparse arguments under the 'Plugins options' group.
1052    Takes the same parameters as the regular argparse `add_argument()` function.
1053
1054    Examples
1055    --------
1056    >>> add_plugin_argument('--foo', type=int, help="This is my help text!")
1057    >>> 
1058    """
1059    from meerschaum._internal.arguments._parser import groups, _seen_plugin_args, parser
1060    from meerschaum.utils.warnings import warn
1061    _parent_plugin_name = _get_parent_plugin()
1062    title = f"Plugin '{_parent_plugin_name}' options" if _parent_plugin_name else 'Custom options'
1063    group_key = 'plugin_' + (_parent_plugin_name or '')
1064    if group_key not in groups:
1065        groups[group_key] = parser.add_argument_group(
1066            title = title,
1067        )
1068        _seen_plugin_args[group_key] = set()
1069    try:
1070        if str(args) not in _seen_plugin_args[group_key]:
1071            groups[group_key].add_argument(*args, **kwargs)
1072            _seen_plugin_args[group_key].add(str(args))
1073    except Exception as e:
1074        warn(e)

Add argparse arguments under the 'Plugins options' group. Takes the same parameters as the regular argparse add_argument() function.

Examples
>>> add_plugin_argument('--foo', type=int, help="This is my help text!")
>>>
def pre_sync_hook(function: Callable[[Any], Any]) -> Callable[[Any], Any]:
131def pre_sync_hook(
132    function: Callable[[Any], Any],
133) -> Callable[[Any], Any]:
134    """
135    Register a function as a sync hook to be executed right before sync.
136    
137    Parameters
138    ----------
139    function: Callable[[Any], Any]
140        The function to execute right before a sync.
141        
142    Returns
143    -------
144    Another function (this is a decorator function).
145
146    Examples
147    --------
148    >>> from meerschaum.plugins import pre_sync_hook
149    >>>
150    >>> @pre_sync_hook
151    ... def log_sync(pipe, **kwargs):
152    ...     print(f"About to sync {pipe} with kwargs:\n{kwargs}.")
153    >>>
154    """
155    with _locks['_pre_sync_hooks']:
156        plugin_name = _get_parent_plugin()
157        try:
158            if plugin_name not in _pre_sync_hooks:
159                _pre_sync_hooks[plugin_name] = []
160            _pre_sync_hooks[plugin_name].append(function)
161        except Exception as e:
162            from meerschaum.utils.warnings import warn
163            warn(e)
164    return function

Register a function as a sync hook to be executed right before sync.

Parameters
----------
function: Callable[[Any], Any]
    The function to execute right before a sync.

Returns
-------
Another function (this is a decorator function).

Examples
--------
>>> from meerschaum.plugins import pre_sync_hook
>>>
>>> @pre_sync_hook
... def log_sync(pipe, **kwargs):
...     print(f"About to sync {pipe} with kwargs:

{kwargs}.")

>

def post_sync_hook(function: Callable[[Any], Any]) -> Callable[[Any], Any]:
167def post_sync_hook(
168    function: Callable[[Any], Any],
169) -> Callable[[Any], Any]:
170    """
171    Register a function as a sync hook to be executed upon completion of a sync.
172    
173    Parameters
174    ----------
175    function: Callable[[Any], Any]
176        The function to execute upon completion of a sync.
177        
178    Returns
179    -------
180    Another function (this is a decorator function).
181
182    Examples
183    --------
184    >>> from meerschaum.plugins import post_sync_hook
185    >>> from meerschaum.utils.misc import interval_str
186    >>> from datetime import timedelta
187    >>>
188    >>> @post_sync_hook
189    ... def log_sync(pipe, success_tuple, duration=None, **kwargs):
190    ...     duration_delta = timedelta(seconds=duration)
191    ...     duration_text = interval_str(duration_delta)
192    ...     print(f"It took {duration_text} to sync {pipe}.")
193    >>>
194    """
195    with _locks['_post_sync_hooks']:
196        try:
197            plugin_name = _get_parent_plugin()
198            if plugin_name not in _post_sync_hooks:
199                _post_sync_hooks[plugin_name] = []
200            _post_sync_hooks[plugin_name].append(function)
201        except Exception as e:
202            from meerschaum.utils.warnings import warn
203            warn(e)
204    return function

Register a function as a sync hook to be executed upon completion of a sync.

Parameters
  • function (Callable[[Any], Any]): The function to execute upon completion of a sync.
Returns
  • Another function (this is a decorator function).
Examples
>>> from meerschaum.plugins import post_sync_hook
>>> from meerschaum.utils.misc import interval_str
>>> from datetime import timedelta
>>>
>>> @post_sync_hook
... def log_sync(pipe, success_tuple, duration=None, **kwargs):
...     duration_delta = timedelta(seconds=duration)
...     duration_text = interval_str(duration_delta)
...     print(f"It took {duration_text} to sync {pipe}.")
>>>