meerschaum.plugins

Expose plugin management APIs from the meerschaum.plugins module.

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

Handle packaging of Meerschaum plugins.

Plugin( name: str, version: Optional[str] = None, user_id: Optional[int] = None, required: Optional[List[str]] = None, attributes: Optional[Dict[str, Any]] = None, archive_path: Optional[pathlib.Path] = None, venv_path: Optional[pathlib.Path] = None, repo_connector: Optional[meerschaum.connectors.APIConnector] = None, repo: Union[meerschaum.connectors.APIConnector, str, NoneType] = None)
53    def __init__(
54        self,
55        name: str,
56        version: Optional[str] = None,
57        user_id: Optional[int] = None,
58        required: Optional[List[str]] = None,
59        attributes: Optional[Dict[str, Any]] = None,
60        archive_path: Optional[pathlib.Path] = None,
61        venv_path: Optional[pathlib.Path] = None,
62        repo_connector: Optional['mrsm.connectors.api.APIConnector'] = None,
63        repo: Union['mrsm.connectors.api.APIConnector', str, None] = None,
64    ):
65        import meerschaum.config.paths as paths
66        from meerschaum._internal.static import STATIC_CONFIG
67        sep = STATIC_CONFIG['plugins']['repo_separator']
68        _repo = None
69        if sep in name:
70            try:
71                name, _repo = name.split(sep)
72            except Exception as e:
73                error(f"Invalid plugin name: '{name}'")
74        self._repo_in_name = _repo
75
76        if attributes is None:
77            attributes = {}
78        self.name = name
79        self.attributes = attributes
80        self.user_id = user_id
81        self._version = version
82        if required:
83            self._required = required
84        self.archive_path = (
85            archive_path if archive_path is not None
86            else paths.PLUGINS_ARCHIVES_RESOURCES_PATH / f"{self.name}.tar.gz"
87        )
88        self.venv_path = (
89            venv_path if venv_path is not None
90            else paths.VIRTENV_RESOURCES_PATH / self.name
91        )
92        self._repo_connector = repo_connector
93        self._repo_keys = repo
name
attributes
user_id
archive_path
venv_path
repo_connector
 96    @property
 97    def repo_connector(self):
 98        """
 99        Return the repository connector for this plugin.
100        NOTE: This imports the `connectors` module, which imports certain plugin modules.
101        """
102        if self._repo_connector is None:
103            from meerschaum.connectors.parse import parse_repo_keys
104
105            repo_keys = self._repo_keys or self._repo_in_name
106            if self._repo_in_name and self._repo_keys and self._repo_keys != self._repo_in_name:
107                error(
108                    f"Received inconsistent repos: '{self._repo_in_name}' and '{self._repo_keys}'."
109                )
110            repo_connector = parse_repo_keys(repo_keys)
111            self._repo_connector = repo_connector
112        return self._repo_connector

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

version
115    @property
116    def version(self):
117        """
118        Return the plugin's module version is defined (`__version__`) if it's defined.
119        """
120        if self._version is None:
121            try:
122                self._version = self.module.__version__
123            except Exception as e:
124                self._version = None
125        return self._version

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

module
128    @property
129    def module(self):
130        """
131        Return the Python module of the underlying plugin,
132        or `None` if the plugin is not installed or its import failed
133        (check `Plugin.import_error` for the swallowed exception).
134        """
135        if '_module' not in self.__dict__ or self.__dict__.get('_module', None) is None:
136            if self.__file__ is None:
137                return None
138
139            from meerschaum.plugins import import_plugins
140            self._module = import_plugins(str(self), warn=False)
141
142        return self._module

Return the Python module of the underlying plugin, or None if the plugin is not installed or its import failed (check Plugin.import_error for the swallowed exception).

import_error: Optional[Exception]
144    @property
145    def import_error(self) -> Union[Exception, None]:
146        """
147        Return the exception raised by this plugin's most recent failed import
148        (i.e. when `Plugin.module` is `None` even though the plugin is installed),
149        otherwise `None`.
150        """
151        from meerschaum.plugins import _plugins_import_errors
152        return _plugins_import_errors.get(self.name, None)

Return the exception raised by this plugin's most recent failed import (i.e. when Plugin.module is None even though the plugin is installed), otherwise None.

requirements_file_path: Optional[pathlib.Path]
180    @property
181    def requirements_file_path(self) -> Union[pathlib.Path, None]:
182        """
183        If a file named `requirements.txt` exists, return its path.
184        """
185        if self.__file__ is None:
186            return None
187        path = pathlib.Path(self.__file__).parent / 'requirements.txt'
188        if not path.exists():
189            return None
190        return path

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

def is_installed(self, **kw) -> bool:
193    def is_installed(self, **kw) -> bool:
194        """
195        Check whether a plugin is correctly installed.
196
197        Returns
198        -------
199        A `bool` indicating whether a plugin exists and is successfully imported.
200        """
201        return self.__file__ is not None

Check whether a plugin is correctly installed.

Returns
  • A bool indicating whether a plugin exists and is successfully imported.
def make_tar(self, debug: bool = False) -> pathlib.Path:
204    def make_tar(self, debug: bool = False) -> pathlib.Path:
205        """
206        Compress the plugin's source files into a `.tar.gz` archive and return the archive's path.
207
208        Parameters
209        ----------
210        debug: bool, default False
211            Verbosity toggle.
212
213        Returns
214        -------
215        A `pathlib.Path` to the archive file's path.
216
217        """
218        import tarfile, pathlib, subprocess, fnmatch
219        from meerschaum.utils.debug import dprint
220        from meerschaum.utils.packages import attempt_import
221        pathspec = attempt_import('pathspec', debug=debug)
222
223        if not self.__file__:
224            from meerschaum.utils.warnings import error
225            error(f"Could not find file for plugin '{self}'.")
226        if '__init__.py' in self.__file__ or os.path.isdir(self.__file__):
227            path = self.__file__.replace('__init__.py', '')
228            is_dir = True
229        else:
230            path = self.__file__
231            is_dir = False
232
233        old_cwd = os.getcwd()
234        real_parent_path = pathlib.Path(os.path.realpath(path)).parent
235        os.chdir(real_parent_path)
236
237        default_patterns_to_ignore = [
238            '.pyc',
239            '__pycache__/',
240            'eggs/',
241            '__pypackages__/',
242            '.git',
243        ]
244
245        def parse_gitignore() -> 'Set[str]':
246            gitignore_path = pathlib.Path(path) / '.gitignore'
247            if not gitignore_path.exists():
248                return set(default_patterns_to_ignore)
249            with open(gitignore_path, 'r', encoding='utf-8') as f:
250                gitignore_text = f.read()
251            return set(pathspec.PathSpec.from_lines(
252                pathspec.patterns.GitWildMatchPattern,
253                default_patterns_to_ignore + gitignore_text.splitlines()
254            ).match_tree(path))
255
256        patterns_to_ignore = parse_gitignore() if is_dir else set()
257
258        if debug:
259            dprint(f"Patterns to ignore:\n{patterns_to_ignore}")
260
261        with tarfile.open(self.archive_path, 'w:gz') as tarf:
262            if not is_dir:
263                tarf.add(f"{self.name}.py")
264            else:
265                for root, dirs, files in os.walk(self.name):
266                    for f in files:
267                        good_file = True
268                        fp = os.path.join(root, f)
269                        for pattern in patterns_to_ignore:
270                            if pattern in str(fp) or f.startswith('.'):
271                                good_file = False
272                                break
273                        if good_file:
274                            if debug:
275                                dprint(f"Adding '{fp}'...")
276                            tarf.add(fp)
277
278        ### clean up and change back to old directory
279        os.chdir(old_cwd)
280
281        ### change to 775 to avoid permissions issues with the API in a Docker container
282        self.archive_path.chmod(0o775)
283
284        if debug:
285            dprint(f"Created archive '{self.archive_path}'.")
286        return self.archive_path

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

Parameters
  • debug (bool, default False): Verbosity toggle.
Returns
  • A pathlib.Path to the archive file's path.
def install( self, skip_deps: bool = False, force: bool = False, debug: bool = False) -> Tuple[bool, str]:
289    def install(
290        self,
291        skip_deps: bool = False,
292        force: bool = False,
293        debug: bool = False,
294    ) -> SuccessTuple:
295        """
296        Extract a plugin's tar archive to the plugins directory.
297        
298        This function checks if the plugin is already installed and if the version is equal or
299        greater than the existing installation.
300
301        Parameters
302        ----------
303        skip_deps: bool, default False
304            If `True`, do not install dependencies.
305
306        force: bool, default False
307            If `True`, continue with installation, even if required packages fail to install.
308
309        debug: bool, default False
310            Verbosity toggle.
311
312        Returns
313        -------
314        A `SuccessTuple` of success (bool) and a message (str).
315
316        """
317        installation_key = self.full_name
318        with _ongoing_installations_lock:
319            if installation_key in _ongoing_installations:
320                return True, f"Already installing plugin '{self}'."
321            _ongoing_installations.add(installation_key)
322
323        try:
324            from meerschaum.utils.packages import _pip_install_lock
325            with _pip_install_lock(self.name):
326                return self._install_with_rollback(
327                    skip_deps=skip_deps,
328                    force=force,
329                    debug=debug,
330                )
331        finally:
332            with _ongoing_installations_lock:
333                _ongoing_installations.discard(installation_key)

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

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

Parameters
  • skip_deps (bool, default False): If True, do not install dependencies.
  • force (bool, default False): If True, continue with installation, even if required packages fail to install.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A SuccessTuple of success (bool) and a message (str).
def remove_archive(self, debug: bool = False) -> Tuple[bool, str]:
625    def remove_archive(
626        self,        
627        debug: bool = False
628    ) -> SuccessTuple:
629        """Remove a plugin's archive file."""
630        if not self.archive_path.exists():
631            return True, f"Archive file for plugin '{self}' does not exist."
632        try:
633            self.archive_path.unlink()
634        except Exception as e:
635            return False, f"Failed to remove archive for plugin '{self}':\n{e}"
636        return True, "Success"

Remove a plugin's archive file.

def remove_venv(self, debug: bool = False) -> Tuple[bool, str]:
639    def remove_venv(
640        self,        
641        debug: bool = False
642    ) -> SuccessTuple:
643        """Remove a plugin's virtual environment."""
644        if not self.venv_path.exists():
645            return True, f"Virtual environment for plugin '{self}' does not exist."
646        try:
647            shutil.rmtree(self.venv_path)
648        except Exception as e:
649            return False, f"Failed to remove virtual environment for plugin '{self}':\n{e}"
650        return True, "Success"

Remove a plugin's virtual environment.

def uninstall(self, debug: bool = False) -> Tuple[bool, str]:
653    def uninstall(self, debug: bool = False) -> SuccessTuple:
654        """
655        Remove a plugin, its virtual environment, and archive file.
656        """
657        from meerschaum.utils.packages import reload_meerschaum
658        from meerschaum.plugins import sync_plugins_symlinks
659        from meerschaum.utils.warnings import warn, info
660        warnings_thrown_count: int = 0
661        max_warnings: int = 3
662
663        if not self.is_installed():
664            info(
665                f"Plugin '{self.name}' doesn't seem to be installed.\n    "
666                + "Checking for artifacts...",
667                stack = False,
668            )
669        else:
670            real_path = pathlib.Path(os.path.realpath(self.__file__))
671            try:
672                if real_path.name == '__init__.py':
673                    shutil.rmtree(real_path.parent)
674                else:
675                    real_path.unlink()
676            except Exception as e:
677                warn(f"Could not remove source files for plugin '{self.name}':\n{e}", stack=False)
678                warnings_thrown_count += 1
679            else:
680                info(f"Removed source files for plugin '{self.name}'.")
681
682        if self.venv_path.exists():
683            success, msg = self.remove_venv(debug=debug)
684            if not success:
685                warn(msg, stack=False)
686                warnings_thrown_count += 1
687            else:
688                info(f"Removed virtual environment from plugin '{self.name}'.")
689
690        success = warnings_thrown_count < max_warnings
691        try:
692            from meerschaum.plugins._origins import remove_plugin_origin
693            remove_plugin_origin(self.name, debug=debug)
694        except Exception:
695            pass
696        sync_plugins_symlinks(debug=debug)
697        self.deactivate_venv(force=True, debug=debug)
698        reload_meerschaum(debug=debug)
699        return success, (
700            f"Successfully uninstalled plugin '{self}'." if success
701            else f"Failed to uninstall plugin '{self}'."
702        )

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

def setup( self, *args: str, debug: bool = False, **kw: Any) -> Union[Tuple[bool, str], bool]:
705    def setup(self, *args: str, debug: bool = False, **kw: Any) -> Union[SuccessTuple, bool]:
706        """
707        If exists, run the plugin's `setup()` function.
708
709        Parameters
710        ----------
711        *args: str
712            The positional arguments passed to the `setup()` function.
713            
714        debug: bool, default False
715            Verbosity toggle.
716
717        **kw: Any
718            The keyword arguments passed to the `setup()` function.
719
720        Returns
721        -------
722        A `SuccessTuple` or `bool` indicating success.
723
724        """
725        from meerschaum.utils.debug import dprint
726        import inspect
727        _setup = None
728        for name, fp in inspect.getmembers(self.module):
729            if name == 'setup' and inspect.isfunction(fp):
730                _setup = fp
731                break
732
733        ### assume success if no setup() is found (not necessary)
734        if _setup is None:
735            return True
736
737        sig = inspect.signature(_setup)
738        has_debug, has_kw = ('debug' in sig.parameters), False
739        for k, v in sig.parameters.items():
740            if '**' in str(v):
741                has_kw = True
742                break
743
744        _kw = {}
745        if has_kw:
746            _kw.update(kw)
747        if has_debug:
748            _kw['debug'] = debug
749
750        if debug:
751            dprint(f"Running setup for plugin '{self}'...")
752        from meerschaum.utils.venv import Venv
753        try:
754            with Venv(self, debug=debug):
755                return_tuple = _setup(*args, **_kw)
756        except Exception as e:
757            return False, str(e)
758
759        if isinstance(return_tuple, tuple):
760            return return_tuple
761        if isinstance(return_tuple, bool):
762            return return_tuple, f"Setup for Plugin '{self.name}' did not return a message."
763        if return_tuple is None:
764            return False, f"Setup for Plugin '{self.name}' returned None."
765        return False, f"Unknown return value from setup for Plugin '{self.name}': {return_tuple}"

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]:
768    def get_dependencies(
769        self,
770        debug: bool = False,
771    ) -> List[str]:
772        """
773        If the Plugin has specified dependencies in a list called `required`, return the list.
774        
775        **NOTE:** Dependecies which start with `'plugin:'` are Meerschaum plugins, not pip packages.
776        Meerschaum plugins may also specify connector keys for a repo after `'@'`.
777
778        Parameters
779        ----------
780        debug: bool, default False
781            Verbosity toggle.
782
783        Returns
784        -------
785        A list of required packages and plugins (str).
786
787        """
788        if '_required' in self.__dict__:
789            return self._required
790
791        ### If the plugin has not yet been imported,
792        ### infer the dependencies from the source text.
793        ### This is not super robust, and it doesn't feel right
794        ### having multiple versions of the logic.
795        ### This is necessary when determining the activation order
796        ### without having import the module.
797        ### For consistency's sake, the module-less method does not cache the requirements.
798        if self.__dict__.get('_module', None) is None:
799            file_path = self.__file__
800            if file_path is None:
801                return []
802            with open(file_path, 'r', encoding='utf-8') as f:
803                text = f.read()
804
805            if 'required' not in text:
806                return []
807
808            ### This has some limitations:
809            ### It relies on `required` being manually declared.
810            ### We lose the ability to dynamically alter the `required` list,
811            ### which is why we've kept the module-reliant method below.
812            import ast, re
813            ### NOTE: This technically would break 
814            ### if `required` was the very first line of the file.
815            req_start_match = re.search(r'\nrequired(:\s*)?.*=', text)
816            if not req_start_match:
817                return []
818            req_start = req_start_match.start()
819            equals_sign = req_start + text[req_start:].find('=')
820
821            ### Dependencies may have brackets within the strings, so push back the index.
822            first_opening_brace = equals_sign + 1 + text[equals_sign:].find('[')
823            if first_opening_brace == -1:
824                return []
825
826            next_closing_brace = equals_sign + 1 + text[equals_sign:].find(']')
827            if next_closing_brace == -1:
828                return []
829
830            start_ix = first_opening_brace + 1
831            end_ix = next_closing_brace
832
833            num_braces = 0
834            while True:
835                if '[' not in text[start_ix:end_ix]:
836                    break
837                num_braces += 1
838                start_ix = end_ix
839                end_ix += text[end_ix + 1:].find(']') + 1
840
841            req_end = end_ix + 1
842            req_text = (
843                text[(first_opening_brace-1):req_end]
844                .lstrip()
845                .replace('=', '', 1)
846                .lstrip()
847                .rstrip()
848            )
849            try:
850                required = ast.literal_eval(req_text)
851            except Exception as e:
852                warn(
853                    f"Unable to determine requirements for plugin '{self.name}' "
854                    + "without importing the module.\n"
855                    + "    This may be due to dynamically setting the global `required` list.\n"
856                    + f"    {e}"
857                )
858                return []
859            return required
860
861        import inspect
862        self.activate_venv(dependencies=False, debug=debug)
863        required = []
864        for name, val in inspect.getmembers(self.module):
865            if name == 'required':
866                required = val
867                break
868        self._required = required
869        self.deactivate_venv(dependencies=False, debug=debug)
870        return required

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

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

Parameters
  • debug (bool, default False): Verbosity toggle.
Returns
  • A list of required packages and plugins (str).
def get_required_plugins(self, debug: bool = False) -> List[Plugin]:
873    def get_required_plugins(self, debug: bool=False) -> List[mrsm.plugins.Plugin]:
874        """
875        Return a list of required Plugin objects.
876        """
877        from meerschaum.utils.warnings import warn
878        from meerschaum.config import get_config
879        from meerschaum._internal.static import STATIC_CONFIG
880        from meerschaum.connectors.parse import is_valid_connector_keys
881        plugins = []
882        _deps = self.get_dependencies(debug=debug)
883        sep = STATIC_CONFIG['plugins']['repo_separator']
884        plugin_names = [
885            _d[len('plugin:'):] for _d in _deps
886            if _d.startswith('plugin:') and len(_d) > len('plugin:')
887        ]
888        default_repo_keys = get_config('meerschaum', 'repository')
889        skipped_repo_keys = set()
890
891        for _plugin_name in plugin_names:
892            if sep in _plugin_name:
893                try:
894                    _plugin_name, _repo_keys = _plugin_name.split(sep)
895                except Exception:
896                    _repo_keys = default_repo_keys
897                    warn(
898                        f"Invalid repo keys for required plugin '{_plugin_name}'.\n    "
899                        + f"Will try to use '{_repo_keys}' instead.",
900                        stack = False,
901                    )
902            else:
903                _repo_keys = default_repo_keys
904
905            if _repo_keys in skipped_repo_keys:
906                continue
907
908            if not is_valid_connector_keys(_repo_keys):
909                warn(
910                    f"Invalid connector '{_repo_keys}'.\n"
911                    f"    Skipping required plugins from repository '{_repo_keys}'",
912                    stack=False,
913                )
914                continue
915
916            plugins.append(Plugin(_plugin_name, repo=_repo_keys))
917
918        return plugins

Return a list of required Plugin objects.

def get_required_packages(self, debug: bool = False) -> List[str]:
921    def get_required_packages(self, debug: bool=False) -> List[str]:
922        """
923        Return the required package names (excluding plugins).
924        """
925        _deps = self.get_dependencies(debug=debug)
926        return [_d for _d in _deps if not _d.startswith('plugin:')]

Return the required package names (excluding plugins).

def activate_venv( self, dependencies: bool = True, init_if_not_exists: bool = True, debug: bool = False, **kw) -> bool:
929    def activate_venv(
930        self,
931        dependencies: bool = True,
932        init_if_not_exists: bool = True,
933        debug: bool = False,
934        **kw
935    ) -> bool:
936        """
937        Activate the virtual environments for the plugin and its dependencies.
938
939        Parameters
940        ----------
941        dependencies: bool, default True
942            If `True`, activate the virtual environments for required plugins.
943
944        Returns
945        -------
946        A bool indicating success.
947        """
948        import meerschaum.config.paths as paths
949        from meerschaum.utils.venv import venv_target_path
950        from meerschaum.utils.packages import activate_venv
951        from meerschaum.utils.misc import make_symlink, is_symlink
952
953        if dependencies:
954            for plugin in self.get_required_plugins(debug=debug):
955                plugin.activate_venv(debug=debug, init_if_not_exists=init_if_not_exists, **kw)
956
957        vtp = venv_target_path(self.name, debug=debug, allow_nonexistent=True)
958        venv_meerschaum_path = vtp / 'meerschaum'
959
960        try:
961            success, msg = True, "Success"
962            if is_symlink(venv_meerschaum_path):
963                if pathlib.Path(os.path.realpath(venv_meerschaum_path)) != paths.PACKAGE_ROOT_PATH:
964                    venv_meerschaum_path.unlink()
965                    success, msg = make_symlink(venv_meerschaum_path, paths.PACKAGE_ROOT_PATH)
966        except Exception as e:
967            success, msg = False, str(e)
968        if not success:
969            warn(
970                f"Unable to create symlink {venv_meerschaum_path} to {paths.PACKAGE_ROOT_PATH}:\n"
971                f"{msg}"
972            )
973
974        return activate_venv(self.name, init_if_not_exists=init_if_not_exists, debug=debug, **kw)

Activate the virtual environments for the plugin and its dependencies.

Parameters
  • dependencies (bool, default True): If True, activate the virtual environments for required plugins.
Returns
  • A bool indicating success.
def deactivate_venv(self, dependencies: bool = True, debug: bool = False, **kw) -> bool:
977    def deactivate_venv(self, dependencies: bool=True, debug: bool = False, **kw) -> bool:
978        """
979        Deactivate the virtual environments for the plugin and its dependencies.
980
981        Parameters
982        ----------
983        dependencies: bool, default True
984            If `True`, deactivate the virtual environments for required plugins.
985
986        Returns
987        -------
988        A bool indicating success.
989        """
990        from meerschaum.utils.packages import deactivate_venv
991        success = deactivate_venv(self.name, debug=debug, **kw)
992        if dependencies:
993            for plugin in self.get_required_plugins(debug=debug):
994                plugin.deactivate_venv(debug=debug, **kw)
995        return success

Deactivate the virtual environments for the plugin and its dependencies.

Parameters
  • dependencies (bool, default True): If True, deactivate the virtual environments for required plugins.
Returns
  • A bool indicating success.
def install_dependencies(self, force: bool = False, debug: bool = False) -> bool:
 998    def install_dependencies(
 999        self,
1000        force: bool = False,
1001        debug: bool = False,
1002    ) -> bool:
1003        """
1004        If specified, install dependencies.
1005        
1006        **NOTE:** Dependencies that start with `'plugin:'` will be installed as
1007        Meerschaum plugins from the same repository as this Plugin.
1008        To install from a different repository, add the repo keys after `'@'`
1009        (e.g. `'plugin:foo@api:bar'`).
1010
1011        Parameters
1012        ----------
1013        force: bool, default False
1014            If `True`, continue with the installation, even if some
1015            required packages fail to install.
1016
1017        debug: bool, default False
1018            Verbosity toggle.
1019
1020        Returns
1021        -------
1022        A bool indicating success.
1023        """
1024        from meerschaum.utils.packages import pip_install, venv_contains_package
1025        from meerschaum.utils.warnings import warn, info
1026        _deps = self.get_dependencies(debug=debug)
1027        if not _deps and self.requirements_file_path is None:
1028            return True
1029
1030        plugins = self.get_required_plugins(debug=debug)
1031        for _plugin in plugins:
1032            if _plugin.name == self.name:
1033                warn(f"Plugin '{self.name}' cannot depend on itself! Skipping...", stack=False)
1034                continue
1035            _success, _msg = _plugin.repo_connector.install_plugin(
1036                _plugin.name, debug=debug, force=force
1037            )
1038            if not _success:
1039                warn(
1040                    f"Failed to install required plugin '{_plugin}' from '{_plugin.repo_connector}'"
1041                    + f" for plugin '{self.name}':\n" + _msg,
1042                    stack = False,
1043                )
1044                if not force:
1045                    warn(
1046                        "Try installing with the `--force` flag to continue anyway.",
1047                        stack = False,
1048                    )
1049                    return False
1050                info(
1051                    "Continuing with installation despite the failure "
1052                    + "(careful, things might be broken!)...",
1053                    icon = False
1054                )
1055
1056
1057        ### First step: parse `requirements.txt` if it exists.
1058        if self.requirements_file_path is not None:
1059            if not pip_install(
1060                requirements_file_path=self.requirements_file_path,
1061                venv=self.name, debug=debug
1062            ):
1063                warn(
1064                    f"Failed to resolve 'requirements.txt' for plugin '{self.name}'.",
1065                    stack = False,
1066                )
1067                if not force:
1068                    warn(
1069                        "Try installing with `--force` to continue anyway.",
1070                        stack = False,
1071                    )
1072                    return False
1073                info(
1074                    "Continuing with installation despite the failure "
1075                    + "(careful, things might be broken!)...",
1076                    icon = False
1077                )
1078
1079
1080        ### Don't reinstall packages that are already included in required plugins.
1081        packages = []
1082        _packages = self.get_required_packages(debug=debug)
1083        accounted_for_packages = set()
1084        for package_name in _packages:
1085            for plugin in plugins:
1086                if venv_contains_package(package_name, plugin.name):
1087                    accounted_for_packages.add(package_name)
1088                    break
1089        packages = [pkg for pkg in _packages if pkg not in accounted_for_packages]
1090
1091        ### Attempt pip packages installation.
1092        if packages:
1093            for package in packages:
1094                if not pip_install(package, venv=self.name, debug=debug):
1095                    warn(
1096                        f"Failed to install required package '{package}'"
1097                        + f" for plugin '{self.name}'.",
1098                        stack = False,
1099                    )
1100                    if not force:
1101                        warn(
1102                            "Try installing with `--force` to continue anyway.",
1103                            stack = False,
1104                        )
1105                        return False
1106                    info(
1107                        "Continuing with installation despite the failure "
1108                        + "(careful, things might be broken!)...",
1109                        icon = False
1110                    )
1111        return True

If specified, install dependencies.

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

Parameters
  • force (bool, default False): If True, continue with the installation, even if some required packages fail to install.
  • debug (bool, default False): Verbosity toggle.
Returns
  • A bool indicating success.
full_name: str
1114    @property
1115    def full_name(self) -> str:
1116        """
1117        Include the repo keys with the plugin's name.
1118        """
1119        from meerschaum._internal.static import STATIC_CONFIG
1120        sep = STATIC_CONFIG['plugins']['repo_separator']
1121        return self.name + sep + str(self.repo_connector)

Include the repo keys with the plugin's name.

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

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

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

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

The FastAPI app will be passed as the only parameter.

Examples
>>> from meerschaum.plugins import api_plugin
>>>
>>> @api_plugin
>>> def initialize_plugin(app):
...     @app.get('/my/new/path')
...     def new_path():
...         return {'message': 'It works!'}
>>>
def dash_plugin(function: Callable[[Any], Any]) -> Callable[[Any], Any]:
305def dash_plugin(function: Callable[[Any], Any]) -> Callable[[Any], Any]:
306    """
307    Execute the function when starting the Dash application.
308    """
309    with _locks['_dash_plugins']:
310        plugin_name = _get_parent_plugin(function=function)
311        try:
312            if plugin_name not in _dash_plugins:
313                _dash_plugins[plugin_name] = []
314            _dash_plugins[plugin_name].append(function)
315        except Exception as e:
316            from meerschaum.utils.warnings import warn
317            warn(e)
318    return function

Execute the function when starting the Dash application.

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

Quickly add pages to the dash application.

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

Import the Meerschaum plugins directory.

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

Emulate the from module import x behavior.

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

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

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

def reload_plugins(plugins: Optional[List[str]] = None, debug: bool = False) -> None:
946def reload_plugins(plugins: Optional[List[str]] = None, debug: bool = False) -> None:
947    """
948    Reload plugins back into memory.
949
950    Parameters
951    ----------
952    plugins: Optional[List[str]], default None
953        The plugins to reload. `None` will reload all plugins.
954
955    """
956    global _synced_symlinks
957    unload_plugins(plugins, debug=debug)
958    _synced_symlinks = 0
959    sync_plugins_symlinks(debug=debug)
960    load_plugins(skip_if_loaded=False, debug=debug)

Reload plugins back into memory.

Parameters
  • plugins (Optional[List[str]], default None): The plugins to reload. None will reload all plugins.
def get_plugins( *to_load, try_import: bool = True) -> Union[Tuple[Plugin], Plugin]:
963def get_plugins(*to_load, try_import: bool = True) -> Union[Tuple[Plugin], Plugin]:
964    """
965    Return a list of `Plugin` objects.
966
967    Parameters
968    ----------
969    to_load:
970        If specified, only load specific plugins.
971        Otherwise return all plugins.
972
973    try_import: bool, default True
974        If `True`, allow for plugins to be imported.
975    """
976    import meerschaum.config.paths as paths
977    import os
978    sync_plugins_symlinks()
979    _plugins = [
980        Plugin(name)
981        for name in (
982            to_load or [
983                (
984                    name if (paths.PLUGINS_RESOURCES_PATH / name).is_dir()
985                    else name[:-3]
986                )
987                for name in os.listdir(paths.PLUGINS_RESOURCES_PATH)
988                if name != '__init__.py'
989            ]
990        )
991    ]
992    plugins = tuple(plugin for plugin in _plugins if plugin.is_installed(try_import=try_import))
993    if len(to_load) == 1:
994        if len(plugins) == 0:
995            raise ValueError(f"Plugin '{to_load[0]}' is not installed.")
996        return plugins[0]
997    return plugins

Return a list of Plugin objects.

Parameters
  • to_load:: If specified, only load specific plugins. Otherwise return all plugins.
  • try_import (bool, default True): If True, allow for plugins to be imported.
def get_data_plugins() -> List[Plugin]:
1015def get_data_plugins() -> List[Plugin]:
1016    """
1017    Only return the modules of plugins with either `fetch()` or `sync()` functions.
1018    """
1019    import inspect
1020    plugins = get_plugins()
1021
1022    ### ponytail: cache on the installed plugins' names.
1023    ### Accessing `plugin.module` re-enters `import_plugins()` (venv activation +
1024    ### a nested `get_plugins()`) for every plugin, so an uncached call costs
1025    ### hundreds of milliseconds. The shell completer calls this on every keystroke
1026    ### (via `get_connector_labels()`), which is what made typing `-c` laggy.
1027    ### Installing or removing a plugin changes the key, so the cache self-invalidates;
1028    ### editing a plugin in place requires a restart to pick up a new `fetch()`/`sync()`.
1029    cache_key = tuple(plugin.name for plugin in plugins)
1030    if cache_key in _data_plugins_cache:
1031        return _data_plugins_cache[cache_key]
1032
1033    data_names = {'sync', 'fetch'}
1034    data_plugins = []
1035    for plugin in plugins:
1036        for name, ob in inspect.getmembers(plugin.module):
1037            if not inspect.isfunction(ob):
1038                continue
1039            if name not in data_names:
1040                continue
1041            data_plugins.append(plugin)
1042
1043    _data_plugins_cache[cache_key] = data_plugins
1044    return data_plugins

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

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

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

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

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

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

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

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

{kwargs}.")

>

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

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

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