diff --git a/Dockerfile b/Dockerfile index 05310bba7f..8426e73552 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,9 +7,5 @@ WORKDIR /code # TODO: Figure out a better solution or wait for it to resolve itself. RUN pip install typing-extensions==3.10.0.2 RUN python3 ./setup.py install -RUN ape plugins add solidity --yes -RUN ape plugins add vyper --yes -RUN ape plugins add infura --yes -RUN ape plugins add etherscan --yes -RUN ape plugins add ens --yes +RUN ape plugins install solidity vyper infura etherscan ens ENTRYPOINT ["ape"] diff --git a/README.md b/README.md index aeacef673d..18dd4c1432 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ $ ape test -k test_only_one_thing --coverage --gas $ ape console --network ethereum:mainnet:infura # Add new plugins to ape -$ ape plugins add plugin-name +$ ape plugins install plugin-name ``` Ape also works as a package. You can use the same networks, accounts, and projects from the ape package as you can in the cli: diff --git a/docs/userguides/projects.md b/docs/userguides/projects.md index a4351ec5f3..57d5da2e77 100644 --- a/docs/userguides/projects.md +++ b/docs/userguides/projects.md @@ -27,8 +27,7 @@ directory determine which compiler plugin `ape` uses. Make sure to install the c missing by adding them to your `ape-config.yaml`'s `plugin` section, or manually adding via the following: ```bash -ape plugins add solidity -ape plugins add vyper +ape plugins install solidity vyper ``` Then, use the following command to compile all contracts in the `contracts/` directory: @@ -195,5 +194,5 @@ not come with `ape` but can be installed by including it in the `plugins` list i manually installing it using the command: ```bash -ape plugins add hardhat +ape plugins install hardhat ``` diff --git a/docs/userguides/quickstart.md b/docs/userguides/quickstart.md index d2c23372b5..6972cb3554 100644 --- a/docs/userguides/quickstart.md +++ b/docs/userguides/quickstart.md @@ -120,7 +120,7 @@ Add any plugins you may need, such as `vyper`. ```bash ape plugins list -a -ape plugins add vyper +ape plugins install vyper ape plugins list -a ``` diff --git a/src/ape/utils.py b/src/ape/utils.py index f07b925023..c2f39a3d50 100644 --- a/src/ape/utils.py +++ b/src/ape/utils.py @@ -347,7 +347,7 @@ def available_plugins(self) -> Set[str]: The available ``ape`` plugins, found from looking at the ``ApeWorx`` Github organization. Returns: - Set[str]: The plugin names. + Set[str]: The plugin names as 'ape_plugin_name' (module-like). """ return { repo.name.replace("-", "_") diff --git a/src/ape_plugins/_cli.py b/src/ape_plugins/_cli.py index 99bf67353c..493f1e3a4e 100644 --- a/src/ape_plugins/_cli.py +++ b/src/ape_plugins/_cli.py @@ -1,21 +1,15 @@ import subprocess import sys -from os import getcwd from pathlib import Path -from typing import Collection, List, Set +from typing import Collection, List, Set, Tuple import click -from ape import config -from ape.cli import ape_cli_context, incompatible_with, skip_confirmation_option +from ape.cli import ape_cli_context, skip_confirmation_option from ape.managers.config import CONFIG_FILE_NAME -from ape.plugins import clean_plugin_name, plugin_manager -from ape.utils import get_package_version, github_client -from ape_plugins.utils import ( - CORE_PLUGINS, - extract_module_and_package_install_names, - is_plugin_installed, -) +from ape.plugins import plugin_manager +from ape.utils import github_client, load_config +from ape_plugins.utils import ApePlugin, ModifyPluginResultHandler @click.group(short_help="Manage ape plugins") @@ -41,7 +35,54 @@ def _format_output(plugins_list: Collection[str]) -> Set: return output -@cli.command(name="list", short_help="Display plugins") +def plugins_argument(): + """ + An argument that is either the given list of plugins + or plugins loaded from the local config file. + """ + + def callback(ctx, param, value: Tuple[str]): + if not value: + ctx.obj.abort("You must give at least one requirement to install.") + + elif len(value) == 1 and Path(value[0]).resolve().exists(): + # User passed in a path to a config file. + config_path = Path(value[0]).expanduser().resolve() + if config_path.name != CONFIG_FILE_NAME: + config_path = config_path / CONFIG_FILE_NAME + + config = load_config(config_path) + plugins = config.get("plugins") or [] + + if not plugins: + ctx.obj.logger.warning(f"No plugins found in config '{config_path}'.") + sys.exit(0) + + return [ApePlugin.from_dict(d) for d in plugins] + + else: + return [ApePlugin(v) for v in value] + + return click.argument( + "plugins", + callback=callback, + nargs=-1, + metavar="PLUGIN-NAMES or path/to/project-dir", + ) + + +def upgrade_option(help: str = "", **kwargs): + """ + A ``click.option`` for upgrading plugins (``--upgrade``). + + Args: + help (str): CLI option help text. Defaults to ``""``. + """ + + return click.option("-U", "--upgrade", default=False, is_flag=True, help=help, **kwargs) + + +@cli.command(name="list") @click.option( "-a", "--all", @@ -52,6 +93,8 @@ def _format_output(plugins_list: Collection[str]) -> Set: ) @ape_cli_context() def _list(cli_ctx, display_all): + """Display plugins""" + installed_first_class_plugins = set() installed_second_class_plugins = set() installed_second_class_plugins_no_version = set() @@ -63,22 +106,22 @@ def _list(cli_ctx, display_all): space_buffer = 4 for name, _ in plugin_list: - version = get_package_version(name) - spacing = (longest_plugin_name - len(name) + space_buffer) * " " - if name in CORE_PLUGINS: + plugin = ApePlugin(name) + spacing = (longest_plugin_name - len(plugin.name) + space_buffer) * " " + if plugin.is_part_of_core: if not display_all: continue # NOTE: Skip 1st class plugins unless specified installed_first_class_plugins.add(name) - elif name in github_client.available_plugins: - installed_second_class_plugins.add(f"{name}{spacing}{version}") + elif plugin.is_available: + installed_second_class_plugins.add(f"{name}{spacing}{plugin.current_version}") installed_second_class_plugins_no_version.add(name) - elif name not in CORE_PLUGINS or name not in github_client.available_plugins: - installed_third_class_plugins.add(f"{name}{spacing}{version}") + elif not plugin.is_part_of_core or not plugin.is_available: + installed_third_class_plugins.add(f"{name}{spacing}{plugin.current_version}") else: - cli_ctx.logger.error(f"{name} is not a plugin.") + cli_ctx.logger.error(f"'{plugin.name}' is not a plugin.") sections = {} @@ -91,22 +134,23 @@ def _list(cli_ctx, display_all): github_client.available_plugins - installed_second_class_plugins_no_version ) + installed_plugins = [] if installed_second_class_plugins: - sections["Installed Plugins"] = [ - installed_second_class_plugins, - installed_third_class_plugins, - ] + installed_plugins.append(installed_second_class_plugins) + + if installed_third_class_plugins: + installed_plugins.append(installed_third_class_plugins) + if installed_plugins: + sections["Installed Plugins"] = installed_plugins elif not display_all: # user has no plugins installed | cant verify installed plugins if available_second: - click.echo("There are available plugins to install, use -a to list all plugins ") + click.echo("No secondary plugins installed. Use '--all' to see available plugins.") if display_all: - available_second_output = _format_output(available_second) if available_second_output: - sections["Available Plugins"] = [available_second_output] else: @@ -122,221 +166,110 @@ def _list(cli_ctx, display_all): click.echo() -def upgrade_option(help: str = "", **kwargs): - """ - A ``click.option`` for upgrading plugins (``--upgrade``). - - Args: - help (str): CLI option help text. Defaults to ``""``. - """ - - return click.option("-U", "--upgrade", default=False, is_flag=True, help=help, **kwargs) - - -@cli.command(short_help="Install an ape plugin") -@click.argument("plugin") -@click.option("--version", help="Specify version (Default is latest)") -@skip_confirmation_option(help="Don't ask for confirmation to add the plugin") +@cli.command() @ape_cli_context() -@upgrade_option( - help="Upgrade the plugin to the newest available version", - cls=incompatible_with(["version"]), -) -def add(cli_ctx, plugin, version, skip_confirmation, upgrade): - args = [sys.executable, "-m", "pip", "install", "--quiet"] - if plugin.startswith("ape"): - cli_ctx.abort(f"Namespace 'ape' in '{plugin}' is not required") - - # NOTE: Add namespace prefix (prevents arbitrary installs) - plugin = f"ape_{clean_plugin_name(plugin)}" - if version: - plugin = f"{plugin}=={version}" - - if plugin in CORE_PLUGINS: - cli_ctx.abort(f"Cannot add 1st class plugin '{plugin}'") - - elif is_plugin_installed(plugin): - if upgrade: - cli_ctx.logger.info(f"Updating '{plugin}'...") - args.append("--upgrade") - args.append(plugin) - result = subprocess.call(args) +@plugins_argument() +@skip_confirmation_option("Don't ask for confirmation to install the plugins") +@upgrade_option(help="Upgrade the plugin to the newest available version") +def install(cli_ctx, plugins, skip_confirmation, upgrade): + """Install plugins""" - if result == 0 and is_plugin_installed(plugin): - cli_ctx.logger.success(f"Plugin '{plugin}' has been upgraded.") - else: - cli_ctx.logger.error(f"Error occurs when updating '{plugin}'.") - sys.exit(1) - else: - cli_ctx.logger.warning( - f"{plugin} is already installed. " - f"Use the '--upgrade' if you want to update '{plugin}'." + failures_occurred = False + for plugin in plugins: + if plugin.is_part_of_core: + cli_ctx.logger.error(f"Cannot install core 'ape' plugin '{plugin.name}'.") + failures_occurred = True + continue + + elif plugin.requested_version is not None and upgrade: + cli_ctx.logger.error( + f"Cannot use '--upgrade' option when specifying " + f"a version for plugin '{plugin.name}'." ) + failures_occurred = True + continue - elif ( - plugin in github_client.available_plugins - or skip_confirmation - or click.confirm(f"Install unknown 3rd party plugin '{plugin}'?") - ): - cli_ctx.logger.info(f"Installing '{plugin}'...") - # NOTE: Be *extremely careful* with this command, as it modifies the user's - # installed packages, to potentially catastrophic results - # NOTE: This is not abstracted into another function *on purpose* - - args.append(plugin) - result = subprocess.call(args) - if result == 0 and is_plugin_installed(plugin): - cli_ctx.logger.success(f"Plugin '{plugin}' has been added.") - else: - cli_ctx.logger.error(f"Errors occurred when adding '{plugin}'.") - sys.exit(1) - + # if plugin is installed but not a 2nd class. It must be a third party + elif not plugin.is_installed and not plugin.is_available: + cli_ctx.logger.warning(f"Plugin '{plugin.name}' is not an trusted plugin.") -@cli.command(short_help="Install all plugins in the local config file") -@ape_cli_context() -@skip_confirmation_option("Don't ask for confirmation to install the plugins") -@upgrade_option(help="Upgrade the plugin to the newest available version") -def install(cli_ctx, skip_confirmation, upgrade): - any_install_failed = False - cwd = getcwd() - config_path = Path(cwd) / CONFIG_FILE_NAME - cli_ctx.logger.info(f"Installing plugins from config file at {config_path}") - plugins = config.get_config("plugins") or [] - for plugin in plugins: + result_handler = ModifyPluginResultHandler(cli_ctx.logger, plugin) args = [sys.executable, "-m", "pip", "install", "--quiet"] - module_name, package_name = extract_module_and_package_install_names(plugin) - available_plugin = module_name in github_client.available_plugins - installed_plugin = is_plugin_installed(module_name) - # if plugin is installed but not a 2nd class. It must be a third party - if not installed_plugin and not available_plugin: - cli_ctx.logger.warning(f"Plugin '{module_name}' is not an trusted plugin.") - any_install_failed = True - # check for installed check the config.yaml - elif installed_plugin and available_plugin: - if upgrade: - cli_ctx.logger.info(f"Updating '{module_name}'...") - args.append("--upgrade") - args.append(module_name) - result = subprocess.call(args) - if result == 0 and is_plugin_installed(module_name): - cli_ctx.logger.success(f"Plugin '{module_name}' has been upgraded.") - else: - cli_ctx.logger.error(f"Errors occurred when upgrading '{module_name}'.") - any_install_failed = True - else: - cli_ctx.logger.warning( - f"{module_name} is already installed. " - f"Use the '--upgrade' option if you want to update '{module_name}'" - ) - - if not is_plugin_installed(module_name) and ( - module_name in github_client.available_plugins + if upgrade: + cli_ctx.logger.info(f"Upgrading '{plugin.name}'...") + args.extend(("--upgrade", plugin.package_name)) + + version_before = plugin.current_version + result = subprocess.call(args) + failures_occurred = result_handler.handle_upgrade_result(result, version_before) + + elif plugin.can_install and ( + plugin.is_available or skip_confirmation - or click.confirm(f"Install unknown 3rd party plugin '{package_name}'?") + or click.confirm(f"Install unknown 3rd party plugin '{plugin.name}'?") ): - cli_ctx.logger.info(f"Installing {package_name}...") + cli_ctx.logger.info(f"Installing {plugin}...") + args.append(plugin.install_str) + # NOTE: Be *extremely careful* with this command, as it modifies the user's # installed packages, to potentially catastrophic results # NOTE: This is not abstracted into another function *on purpose* - if upgrade: - args.append("--upgrade") - args.append(package_name) result = subprocess.call(args) - plugin_got_installed = is_plugin_installed(module_name) - if result == 0 and plugin_got_installed: - cli_ctx.logger.success(f"Plugin '{module_name}' has been added.") - else: - cli_ctx.logger.error(f"Errors occurred when adding '{package_name}'.") - any_install_failed = True - if any_install_failed: + failures_occurred = not result_handler.handle_install_result(result) + + else: + cli_ctx.logger.warning( + f"'{plugin.name}' is already installed. Did you mean to include '--upgrade'." + ) + + if failures_occurred: sys.exit(1) -@cli.command(short_help="Uninstall all plugins in the local config file") +@cli.command() +@plugins_argument() @ape_cli_context() @skip_confirmation_option("Don't ask for confirmation to install the plugins") -def uninstall(cli_ctx, skip_confirmation): - any_uninstall_failed = False - cwd = getcwd() - config_path = Path(cwd) / CONFIG_FILE_NAME - cli_ctx.logger.info(f"Uninstalling plugins from config file at {config_path}") +def uninstall(cli_ctx, plugins, skip_confirmation): + """Uninstall plugins""" - plugins = config.get_config("plugins") or [] + failures_occurred = False + did_warn_about_version = False for plugin in plugins: - module_name, package_name = extract_module_and_package_install_names(plugin) + if plugin.requested_version is not None and not did_warn_about_version: + cli_ctx.logger.warning("Specifying a version when uninstalling is not necessary.") + did_warn_about_version = True - available_plugin = module_name in github_client.available_plugins - plugin_still_installed = is_plugin_installed(module_name) + result_handler = ModifyPluginResultHandler(cli_ctx.logger, plugin) # if plugin is installed but not a 2nd class. It must be a third party - if plugin_still_installed and not available_plugin: + if plugin.is_installed and not plugin.is_available: cli_ctx.logger.warning( - f"Plugin '{module_name}' is not installed but not in available plugins." - f" Please uninstall outside of Ape." + f"Plugin '{plugin.name}' is installed but not in available plugins. " + f"Please uninstall outside of Ape." ) - any_uninstall_failed = True - pass + failures_occurred = True + continue + # check for installed check the config.yaml - elif not plugin_still_installed: - cli_ctx.logger.warning(f"Plugin '{module_name}' is not installed.") - any_uninstall_failed = True - pass + elif not plugin.is_installed: + cli_ctx.logger.warning(f"Plugin '{plugin.name}' is not installed.") + failures_occurred = True + continue # if plugin is installed and 2nd class. We should uninstall it - if plugin_still_installed and (available_plugin or skip_confirmation): - cli_ctx.logger.info(f"Uninstalling {package_name}...") + if plugin.is_installed and ( + skip_confirmation or click.confirm(f"Remove plugin '{plugin}'?") + ): + cli_ctx.logger.info(f"Uninstalling '{plugin.name}'...") + args = [sys.executable, "-m", "pip", "uninstall", "--quiet", "-y", plugin.package_name] + # NOTE: Be *extremely careful* with this command, as it modifies the user's # installed packages, to potentially catastrophic results # NOTE: This is not abstracted into another function *on purpose* - - args = [sys.executable, "-m", "pip", "uninstall", "--quiet"] - if skip_confirmation: - args.append("-y") - args.append(package_name) - result = subprocess.call(args) - plugin_still_installed = is_plugin_installed(module_name) + failures_occurred = not result_handler.handle_uninstall_result(result) - if result == 0 and not plugin_still_installed: - cli_ctx.logger.success(f"Plugin '{package_name}' has been removed.") - - else: - cli_ctx.logger.error(f"Failed to remove '{package_name}'.") - any_uninstall_failed = True - if any_uninstall_failed: + if failures_occurred: sys.exit(1) - - -@cli.command(short_help="Uninstall an ape plugin") -@click.argument("plugin") -@skip_confirmation_option("Don't ask for confirmation to remove the plugin") -@ape_cli_context() -def remove(cli_ctx, plugin, skip_confirmation): - if plugin.startswith("ape"): - cli_ctx.abort(f"Namespace 'ape' in '{plugin}' is not required") - - # NOTE: Add namespace prefix (match behavior of ``install``) - plugin = f"ape_{clean_plugin_name(plugin)}" - - if not is_plugin_installed(plugin): - cli_ctx.abort(f"Plugin '{plugin}' is not installed") - - elif plugin in CORE_PLUGINS: - cli_ctx.abort(f"Cannot remove 1st class plugin '{plugin}'") - - elif skip_confirmation or click.confirm( - f"Remove plugin '{plugin} ({get_package_version(plugin)})'" - ): - # NOTE: Be *extremely careful* with this command, as it modifies the user's - # installed packages, to potentially catastrophic results - # NOTE: This is not abstracted into another function *on purpose* - result = subprocess.call( - [sys.executable, "-m", "pip", "uninstall", "--quiet", "-y", plugin] - ) - plugin_still_installed = is_plugin_installed(plugin) - if result == 0 and not plugin_still_installed: - cli_ctx.logger.success(f"Plugin '{plugin}' has been removed.") - else: - cli_ctx.logger.error(f"Failed to remove '{plugin}'.") - sys.exit(1) diff --git a/src/ape_plugins/utils.py b/src/ape_plugins/utils.py index 7bcc9fdf61..c21474f3f2 100644 --- a/src/ape_plugins/utils.py +++ b/src/ape_plugins/utils.py @@ -1,54 +1,165 @@ import subprocess import sys -from typing import Dict, Tuple +from typing import Dict, List, Optional from ape.__modules__ import __modules__ from ape.exceptions import ConfigError +from ape.logging import CliLogger +from ape.plugins import clean_plugin_name +from ape.utils import get_package_version, github_client # Plugins maintained OSS by ApeWorX (and trusted) CORE_PLUGINS = {p for p in __modules__ if p != "ape"} -def is_plugin_installed(plugin: str) -> bool: - plugin = plugin.replace("_", "-") - pip_freeze_output_lines = [ - r - for r in subprocess.check_output([sys.executable, "-m", "pip", "freeze"]) - .decode() - .split("\n") - if r +def _pip_freeze_plugins() -> List[str]: + # NOTE: This uses 'pip' subprocess because often we have installed + # in the same process and this session's site-packages won't know about it yet. + output = subprocess.check_output([sys.executable, "-m", "pip", "freeze"]) + lines = [ + p + for p in output.decode().split("\n") + if p.startswith("ape-") or (p.startswith("-e") and "ape-" in p) ] - for package in pip_freeze_output_lines: - if package.split("==")[0] == plugin: - return True - elif package.split(".git")[0].split("/")[-1] == plugin: - return True + new_lines = [] + for package in lines: + if "==" in package: + new_lines.append(package) + elif "-e" in package: + new_lines.append(package.split(".git")[0].split("/")[-1]) + + return new_lines + + +class ApePlugin: + def __init__(self, name: str): + parts = name.split("==") + self.name = clean_plugin_name(parts[0]) # 'plugin-name' + self.requested_version = parts[-1] if len(parts) == 2 else None + self.package_name = f"ape-{self.name}" # 'ape-plugin-name' + self.module_name = f"ape_{self.name.replace('-', '_')}" # 'ape_plugin_name' + self.current_version = get_package_version(self.package_name) + + def __str__(self): + return self.name if not self.requested_version else f"{self.name}=={self.requested_version}" + + @classmethod + def from_dict(cls, data: Dict) -> "ApePlugin": + if "name" not in data: + expected_format = ( + "plugins:\n - name: \n version: # optional" + ) + raise ConfigError( + f"Config item mis-configured. Expected format:\n\n{expected_format}\n" + ) + + name = data.pop("name") + if "version" in data: + version = data.pop("version") + name = f"{name}=={version}" + + if data: + keys_str = ", ".join(data.keys()) + raise ConfigError(f"Unknown keys for plugins entry '{name}': '{keys_str}'.") + + return ApePlugin(name) + + @property + def install_str(self) -> str: + pip_str = str(self.package_name) + if self.requested_version: + pip_str = f"{pip_str}=={self.requested_version}" + + return pip_str - return False + @property + def can_install(self) -> bool: + requesting_different_version = ( + self.requested_version is not None and self.requested_version != self.current_version + ) + return not self.is_installed or requesting_different_version + @property + def is_part_of_core(self) -> bool: + return self.module_name in CORE_PLUGINS -def extract_module_and_package_install_names(item: Dict) -> Tuple[str, str]: - """ - Extracts the module name and package name from the configured - plugin. The package name includes `==` if the version is - specified in the config. - """ - try: - name = item["name"] - module_name = f"ape_{name.replace('-', '_')}" - package_install_name = f"ape-{name}" - version = item.get("version") + @property + def is_installed(self) -> bool: + ape_packages = [r.split("==")[0] for r in _pip_freeze_plugins()] + return self.package_name in ape_packages - if version: - package_install_name = f"{package_install_name}=={version}" + @property + def pip_freeze_version(self) -> Optional[str]: + for package in _pip_freeze_plugins(): + parts = package.split("==") + if len(parts) != 2: + continue - return module_name, package_install_name + name = parts[0] + if name == self.package_name: + version_str = parts[-1] + return version_str - except Exception as err: - raise _get_config_error() from err + return None + + @property + def is_available(self) -> bool: + return self.module_name in github_client.available_plugins + + +class ModifyPluginResultHandler: + def __init__(self, logger: CliLogger, plugin: ApePlugin): + self._logger = logger + self._plugin = plugin + + def handle_install_result(self, result) -> bool: + if not self._plugin.is_installed: + self._log_modify_failed("install") + return False + elif result != 0: + self._log_errors_occurred("installing") + return False + else: + plugin_id = f"{self._plugin.name}=={self._plugin.pip_freeze_version}" + self._logger.success(f"Plugin '{plugin_id}' has been installed.") + return True + + def handle_upgrade_result(self, result, version_before: str) -> bool: + if result != 0: + self._log_errors_occurred("upgrading") + return False + + pip_freeze_version = self._plugin.pip_freeze_version + if version_before == pip_freeze_version or not pip_freeze_version: + if self._plugin.requested_version: + self._logger.info( + f"'{self._plugin.name}' already has version '{self._plugin.requested_version}'." + ) + else: + self._logger.info(f"'{self._plugin.name}' already up to date.") + + return True + else: + self._logger.success( + f"Plugin '{self._plugin.name}' has been " + f"upgraded to version {self._plugin.pip_freeze_version}." + ) + return True + + def handle_uninstall_result(self, result) -> bool: + if self._plugin.is_installed: + self._log_modify_failed("uninstall") + return False + elif result != 0: + self._log_errors_occurred("uninstalling") + return False + else: + self._logger.success(f"Plugin '{self._plugin.name}' has been uninstalled.") + return True + def _log_errors_occurred(self, verb: str): + self._logger.error(f"Errors occurred when {verb} '{self._plugin}'.") -def _get_config_error() -> ConfigError: - expected_format = "plugins:\n - name: \n version: # optional" - return ConfigError(f"Config item mis-configured. Expected format:\n\n{expected_format}\n") + def _log_modify_failed(self, verb: str): + self._logger.error(f"Failed to {verb} plugin '{self._plugin}.")