diff --git a/docs/source/cli/index.rst b/docs/source/cli/index.rst index e4152070197..548a8798fc8 100644 --- a/docs/source/cli/index.rst +++ b/docs/source/cli/index.rst @@ -1239,14 +1239,14 @@ List plugins that are installed locally. .. code-block:: text - fiftyone plugins info [-h] NAME + fiftyone plugins info [-h] NAME_OR_DIR **Arguments** .. code-block:: text positional arguments: - NAME the plugin name + NAME_OR_DIR the plugin name or directory optional arguments: -h, --help show this help message and exit @@ -1258,6 +1258,9 @@ List plugins that are installed locally. # Prints information about a plugin fiftyone plugins info + # Prints information about a plugin in a given directory + fiftyone plugins info + .. _cli-fiftyone-plugins-download: Download plugins diff --git a/fiftyone/core/cli.py b/fiftyone/core/cli.py index 4063b1dbd6a..3a7bb0bd1f3 100644 --- a/fiftyone/core/cli.py +++ b/fiftyone/core/cli.py @@ -3596,7 +3596,9 @@ def execute(parser, args): def _print_plugins_list(enabled, builtin, names_only): - plugin_defintions = fop.list_plugins(enabled=enabled, builtin=builtin) + plugin_defintions = fop.list_plugins( + enabled=enabled, builtin=builtin, shadowed="all" + ) if names_only: for pd in plugin_defintions: @@ -3606,19 +3608,37 @@ def _print_plugins_list(enabled, builtin, names_only): enabled_plugins = set(fop.list_enabled_plugins()) - headers = ["plugin", "version", "enabled", "builtin", "directory"] + shadowed_paths = set() + for pd in plugin_defintions: + if pd.shadow_paths: + shadowed_paths.update(pd.shadow_paths) + + headers = [ + "plugin", + "version", + "enabled", + "builtin", + "shadowed", + "directory", + ] rows = [] for pd in plugin_defintions: + shadowed = pd.directory in shadowed_paths + enabled = (pd.builtin or pd.name in enabled_plugins) and not shadowed rows.append( { "plugin": pd.name, "version": pd.version or "", - "enabled": pd.builtin or pd.name in enabled_plugins, + "enabled": enabled, "builtin": pd.builtin, + "shadowed": shadowed, "directory": pd.directory, } ) + if not shadowed_paths: + headers.remove("shadowed") + records = [tuple(_format_cell(r[key]) for key in headers) for r in rows] table_str = tabulate(records, headers=headers, tablefmt=_TABLE_FORMAT) @@ -3632,22 +3652,35 @@ class PluginsInfoCommand(Command): # Prints information about a plugin fiftyone plugins info + + # Prints information about a plugin in a given directory + fiftyone plugins info """ @staticmethod def setup(parser): - parser.add_argument("name", metavar="NAME", help="the plugin name") + parser.add_argument( + "name_or_dir", + metavar="NAME_OR_DIR", + help="the plugin name or directory", + ) @staticmethod def execute(parser, args): - _print_plugin_info(args.name) + _print_plugin_info(args.name_or_dir) -def _print_plugin_info(name): - plugin_defintion = fop.get_plugin(name) +def _print_plugin_info(name_or_dir): + try: + pd = fop.get_plugin(name_or_dir) + except: + if os.path.isdir(name_or_dir): + pd = fop.get_plugin(plugin_dir=name_or_dir) + else: + raise - d = plugin_defintion.to_dict() - d["directory"] = plugin_defintion.directory + d = pd.to_dict() + d["directory"] = pd.directory _print_dict_as_table(d) diff --git a/fiftyone/plugins/core.py b/fiftyone/plugins/core.py index 3967ecb9674..54cbb5a88c9 100644 --- a/fiftyone/plugins/core.py +++ b/fiftyone/plugins/core.py @@ -5,7 +5,6 @@ | `voxel51.com `_ | """ -from collections import Counter from dataclasses import dataclass import json import logging @@ -13,6 +12,7 @@ from packaging.requirements import Requirement import re import shutil +from typing import Optional import yaml @@ -44,19 +44,22 @@ class PluginPackage: name: str path: str + shadow_paths: Optional[list[str]] = None def __repr__(self): return f"Plugin(name={self.name}, path={self.path})" -def list_plugins(enabled=True, builtin=False): - """Returns the definitions of available plugins. +def list_plugins(enabled=True, builtin=False, shadowed=False): + """Lists available plugins. Args: enabled (True): whether to include only enabled plugins (True) or only disabled plugins (False) or all plugins ("all") builtin (False): whether to include only builtin plugins (True) or only non-builtin plugins (False) or all plugins ("all") + shadowed (False): whether to include only "shadowed" duplicate plugins + (True) or only usable plugins (False) or all plugins ("all") Returns: a list of :class:`PluginDefinition` instances @@ -67,12 +70,17 @@ def list_plugins(enabled=True, builtin=False): if builtin == "all": builtin = None + if shadowed == "all": + shadowed = None + plugins = [] - for p in _list_plugins(enabled=enabled, builtin=builtin): + for p in _list_plugins( + enabled=enabled, builtin=builtin, shadowed=shadowed + ): try: - plugins.append(_load_plugin_definition(p)) + plugins.append(_load_plugin_definition(plugin=p)) except: - logger.debug(f"Failed to parse plugin at '{p.path}'") + logger.info(f"Failed to parse plugin at '{p.path}'") return plugins @@ -141,7 +149,8 @@ def list_downloaded_plugins(): Returns: a list of plugin names """ - return _list_plugins_by_name(builtin=False) + plugins = _list_plugins(builtin=False, shadowed=False) + return [p.name for p in plugins] def list_enabled_plugins(): @@ -150,7 +159,8 @@ def list_enabled_plugins(): Returns: a list of plugin names """ - return _list_plugins_by_name(enabled=True, builtin=False) + plugins = _list_plugins(enabled=True, builtin=False, shadowed=False) + return [p.name for p in plugins] def list_disabled_plugins(): @@ -159,20 +169,25 @@ def list_disabled_plugins(): Returns: a list of plugin names """ - return _list_plugins_by_name(enabled=False, builtin=False) + plugins = _list_plugins(enabled=False, builtin=False, shadowed=False) + return [p.name for p in plugins] -def get_plugin(name): +def get_plugin(name=None, plugin_dir=None): """Gets the definition for the given plugin. Args: - name: the plugin name + name (None): the plugin name + plugin_dir (None): a directory containing the plugin Returns: a :class:`PluginDefinition` """ + if plugin_dir is not None: + return _load_plugin_definition(plugin_dir=plugin_dir) + plugin = _get_plugin(name) - return _load_plugin_definition(plugin) + return _load_plugin_definition(plugin=plugin) def find_plugin(name): @@ -233,9 +248,12 @@ def download_plugin(url_or_gh_repo, plugin_names=None, overwrite=False): elif plugin_names is not None: plugin_names = set(plugin_names) - existing_plugin_dirs = {p.name: p.path for p in _list_plugins()} - + existing_plugins = { + p.name: p for p in list_plugins(enabled="all", builtin="all") + } downloaded_plugins = {} + skipped_plugins = set() + with etau.TempDir() as tmpdir: root_dir = tmpdir @@ -278,15 +296,21 @@ def download_plugin(url_or_gh_repo, plugin_names=None, overwrite=False): logger.debug(f"Skipping unwanted plugin '{plugin.name}'") continue - existing_dir = existing_plugin_dirs.get(plugin.name, None) - if existing_dir is not None: + existing_plugin = existing_plugins.get(plugin.name, None) + if existing_plugin is not None: if not overwrite: + skipped_plugins.add(plugin.name) logger.info(f"Skipping existing plugin '{plugin.name}'") continue - logger.debug(f"Overwriting existing plugin '{plugin.name}'") - etau.delete_dir(existing_dir) - plugin_dir = existing_dir + if existing_plugin.builtin: + raise ValueError( + f"Cannot overwrite builtin plugin '{plugin.name}'" + ) + + logger.info(f"Overwriting existing plugin '{plugin.name}'") + plugin_dir = existing_plugin.directory + etau.delete_dir(plugin_dir) else: plugin_dir = _recommend_plugin_dir(plugin.name) @@ -295,7 +319,11 @@ def download_plugin(url_or_gh_repo, plugin_names=None, overwrite=False): downloaded_plugins[plugin.name] = plugin_dir if plugin_names is not None: - missing_plugins = set(plugin_names) - set(downloaded_plugins.keys()) + missing_plugins = ( + set(plugin_names) + - set(downloaded_plugins.keys()) + - skipped_plugins + ) if missing_plugins: logger.warning(f"Plugins not found: {missing_plugins}") @@ -461,12 +489,20 @@ def create_plugin( the directory containing the created plugin """ if outdir is None: - existing_plugin_dirs = {p.name: p.path for p in _list_plugins()} - if plugin_name in existing_plugin_dirs: + existing_plugins = { + p.name: p for p in list_plugins(enabled="all", builtin="all") + } + existing_plugin = existing_plugins.get(plugin_name, None) + if existing_plugin is not None: if not overwrite: raise ValueError(f"Plugin '{plugin_name}' already exists") - plugin_dir = existing_plugin_dirs[plugin_name] + if existing_plugin.builtin: + raise ValueError( + f"Cannot overwrite builtin plugin '{plugin_name}'" + ) + + plugin_dir = existing_plugin.directory logger.info(f"Overwriting existing plugin '{plugin_name}'") else: plugin_dir = os.path.join(fo.config.plugins_dir, plugin_name) @@ -475,7 +511,7 @@ def create_plugin( raise ValueError(f"Directory '{outdir}' already exists") plugin_dir = outdir - logger.debug(f"Overwriting existing plugin at '{plugin_dir}'") + logger.info(f"Overwriting existing plugin at '{plugin_dir}'") else: plugin_dir = _recommend_plugin_dir(plugin_name) logger.info(f"Creating plugin at '{plugin_dir}'") @@ -540,7 +576,7 @@ def _parse_plugin_metadata(metadata_path): return PluginPackage(plugin_name, plugin_path) -def _list_plugins(enabled=None, builtin=None): +def _list_plugins(enabled=None, builtin=None, shadowed=None): plugin_paths = [] if builtin in (True, None): @@ -557,9 +593,11 @@ def _list_plugins(enabled=None, builtin=None): plugin = _parse_plugin_metadata(metadata_path) plugins.append(plugin) except: - logger.debug(f"Failed to load plugin name from '{metadata_path}'") + logger.info(f"Failed to load plugin name from '{metadata_path}'") continue + plugins = _handle_shadowed(plugins, shadowed=shadowed) + disabled = set(_list_disabled_plugins()) if enabled is True: @@ -571,19 +609,26 @@ def _list_plugins(enabled=None, builtin=None): return plugins -def _list_plugins_by_name( - enabled=None, builtin=None, check_for_duplicates=True -): - plugin_names = [ - p.name for p in _list_plugins(enabled=enabled, builtin=builtin) - ] +def _handle_shadowed(plugins, shadowed=None): + existing_plugins = {} + + _plugins = [] + for plugin in plugins: + existing_plugin = existing_plugins.get(plugin.name, None) + if existing_plugin is not None: + if existing_plugin.shadow_paths is None: + existing_plugin.shadow_paths = [] + existing_plugin.shadow_paths.append(plugin.path) - if check_for_duplicates: - dups = [n for n, c in Counter(plugin_names).items() if c > 1] - if dups: - raise ValueError(f"Found multiple plugins with name {dups}") + if shadowed in (True, None): + _plugins.append(plugin) + else: + if shadowed in (False, None): + _plugins.append(plugin) + + existing_plugins[plugin.name] = plugin - return plugin_names + return _plugins def _iter_plugin_metadata_files(root_dir=None, strict=False): @@ -621,24 +666,25 @@ def _iter_plugin_metadata_files(root_dir=None, strict=False): break -def _get_plugin(name, enabled=None, builtin=None, check_for_duplicates=True): - plugin = None - for _plugin in _list_plugins(enabled=enabled, builtin=builtin): - if _plugin.name == name: - if check_for_duplicates and plugin is not None: - raise ValueError(f"Multiple plugins found with name '{name}'") +def _get_plugin(name, enabled=None, builtin=None): + for plugin in _list_plugins( + enabled=enabled, builtin=builtin, shadowed=False + ): + if plugin.name == name: + return plugin - plugin = _plugin + raise ValueError(f"Plugin '{name}' not found") - if plugin is None: - raise ValueError(f"Plugin '{name}' not found") - - return plugin +def _load_plugin_definition(plugin=None, plugin_dir=None): + if plugin is not None: + plugin_dir = plugin.path + shadow_paths = plugin.shadow_paths + else: + shadow_paths = None -def _load_plugin_definition(plugin): - metadata_path = _find_plugin_metadata_file(plugin.path) - return PluginDefinition.from_disk(metadata_path) + metadata_path = _find_plugin_metadata_file(plugin_dir) + return PluginDefinition.from_disk(metadata_path, shadow_paths=shadow_paths) def _list_disabled_plugins(): diff --git a/fiftyone/plugins/definitions.py b/fiftyone/plugins/definitions.py index 04c48003029..bee4957e38a 100644 --- a/fiftyone/plugins/definitions.py +++ b/fiftyone/plugins/definitions.py @@ -22,13 +22,16 @@ class PluginDefinition(object): Args: directory: the directory containing the plugin metadata: a plugin metadata dict + shadow_paths (None): a list of plugin directories that this plugin + shadows """ _REQUIRED_METADATA_KEYS = ["name"] - def __init__(self, directory, metadata): + def __init__(self, directory, metadata, shadow_paths=None): self._directory = directory self._metadata = metadata + self._shadow_paths = shadow_paths self._validate() @property @@ -46,6 +49,11 @@ def builtin(self): """Whether the plugin is a builtin plugin.""" return self.directory.startswith(fpc.BUILTIN_PLUGINS_DIR) + @property + def shadow_paths(self): + """A list of plugin directories that this plugin shadows.""" + return self._shadow_paths + @property def author(self): """The author of the plugin.""" @@ -194,6 +202,7 @@ def to_dict(self): """ return { "name": self.name, + "builtin": self.builtin, "author": self.author, "version": self.version, "url": self.url, @@ -211,15 +220,17 @@ def to_dict(self): "has_js": self.has_js, "server_path": self.server_path, "secrets": self.secrets, - "builtin": self.builtin, + "shadow_paths": self.shadow_paths, } @classmethod - def from_disk(cls, metadata_path): + def from_disk(cls, metadata_path, shadow_paths=None): """Creates a :class:`PluginDefinition` for the given metadata file. Args: metadata_path: the path to a plugin ``.yaml`` file + shadow_paths (None): a list of plugin directories that this plugin + shadows Returns: a :class:`PluginDefinition` @@ -228,7 +239,7 @@ def from_disk(cls, metadata_path): with open(metadata_path, "r") as f: metadata = yaml.safe_load(f) - return cls(dirpath, metadata) + return cls(dirpath, metadata, shadow_paths=shadow_paths) def _validate(self): missing = [