diff --git a/docs/userguides/installing_plugins.md b/docs/userguides/installing_plugins.md index 32d20adcfe..9821f1de76 100644 --- a/docs/userguides/installing_plugins.md +++ b/docs/userguides/installing_plugins.md @@ -64,6 +64,25 @@ Or from the CLI like: ape plugins install "foobar@git+https://github.com//ape-foobar.git@" ``` +## Plugin Versions + +By default, `ape plugins` commands install plugins within your current Ape version specification. +For example, if you have Ape 0.6.5 installed and you install `ape-tokens` without specifying a version, it defaults to `ape-tokens>=0.6.0,<0.7` so it is compatible does not change your Ape version. +To upgrade plugins to a new minor version, you have to first update Ape. + +We provide an easy way to update your entire Ape ecosystem using the command: + +```shell +ape plugins update +``` + +Now, both Ape and all the plugins will maximally update. +Alternatively, you use the `change-version` command to install a specific version of everything at once: + +```shell +ape plugins change-version 0.6.0 +``` + ## Plugin Types There are many types of plugins available, including compilers, providers, networks, and CLI-based plugins. diff --git a/src/ape_plugins/_cli.py b/src/ape_plugins/_cli.py index fc45854f24..1b15b3c171 100644 --- a/src/ape_plugins/_cli.py +++ b/src/ape_plugins/_cli.py @@ -1,11 +1,13 @@ import subprocess import sys from pathlib import Path -from typing import List, Tuple +from typing import Any, Dict, List, Tuple import click +from packaging.version import Version from ape.cli import ape_cli_context, skip_confirmation_option +from ape.logging import logger from ape.managers.config import CONFIG_FILE_NAME from ape.utils import github_client, load_config from ape_plugins.utils import ( @@ -13,6 +15,8 @@ PluginMetadata, PluginMetadataList, PluginType, + _pip_freeze, + ape_version, ) @@ -113,65 +117,37 @@ def _list(cli_ctx, to_display): @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): +def install(cli_ctx, plugins: List[PluginMetadata], skip_confirmation: bool, upgrade: bool): """Install plugins""" failures_occurred = False + + # Track the operations until the end. This way, if validation + # fails on one, we can error-out before installing anything. + install_list: List[Dict[str, Any]] = [] + for plugin in plugins: - if plugin.in_core: - cli_ctx.logger.error(f"Cannot install core 'ape' plugin '{plugin.name}'.") + result = plugin._prepare_install(upgrade=upgrade, skip_confirmation=skip_confirmation) + if result: + install_list.append(result) + else: failures_occurred = True - continue - elif plugin.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 + # NOTE: Be *extremely careful* with `subprocess.call`, as it modifies the user's + # installed packages, to potentially catastrophic results + for op in install_list: + if not op: continue - # if plugin is installed but not trusted. It must be a third party - elif plugin.is_third_party: - cli_ctx.logger.warning(f"Plugin '{plugin.name}' is not an trusted plugin.") - - result_handler = ModifyPluginResultHandler(plugin) - pip_arguments = [sys.executable, "-m", "pip", "install"] - - if upgrade: - cli_ctx.logger.info(f"Upgrading '{plugin.name}'...") - pip_arguments.extend(("--upgrade", plugin.package_name)) - - version_before = plugin.current_version - - # NOTE: There can issues when --quiet is not at the end. - pip_arguments.append("--quiet") - - result = subprocess.call(pip_arguments) - - # Returns ``True`` when upgraded successfully - failures_occurred = not result_handler.handle_upgrade_result(result, version_before) - - elif plugin.can_install and ( - plugin.is_available - or skip_confirmation - or click.confirm(f"Install the '{plugin.name}' plugin?") - ): - cli_ctx.logger.info(f"Installing {plugin}...") - - # NOTE: There can issues when --quiet is not at the end. - pip_arguments.extend((plugin.install_str, "--quiet")) - - # 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(pip_arguments) - failures_occurred = not result_handler.handle_install_result(result) + handler = op["result_handler"] + call_result = subprocess.call(op["args"]) + if "version_before" in op: + success = not handler.handle_upgrade_result(call_result, op["version_before"]) + failures_occurred = not failures_occurred and success else: - cli_ctx.logger.warning( - f"'{plugin.name}' is already installed. Did you mean to include '--upgrade'?" - ) + success = not handler.handle_install_result(call_result) + failures_occurred = not failures_occurred and success if failures_occurred: sys.exit(1) @@ -223,3 +199,55 @@ def uninstall(cli_ctx, plugins, skip_confirmation): if failures_occurred: sys.exit(1) + + +@cli.command() +def update(): + """ + Update Ape and all plugins to the next version + """ + + _change_version(ape_version.next_version_range) + + +def _version_callack(ctx, param, value): + obj = Version(value) + version_str = f"0.{obj.minor}.0" if obj.major == 0 else f"{obj.major}.0.0" + return f"=={version_str}" + + +@cli.command() +@click.argument("version", callback=_version_callack) +def change_version(version): + """ + Change ape and all plugins version + """ + + _change_version(version) + + +def _change_version(spec: str): + # Update all the plugins. + # This will also update core Ape. + # NOTE: It is possible plugins may depend on each other and may update in + # an order causing some error codes to pop-up, so we ignore those for now. + for plugin in _pip_freeze.get_plugins(): + logger.info(f"Updating {plugin} ...") + name = plugin.split("=")[0].strip() + subprocess.call([sys.executable, "-m", "pip", "install", f"{name}{spec}", "--quiet"]) + + # This check is for verifying the update and shouldn't actually do anything. + logger.info("Updating Ape core ...") + completed_process = subprocess.run( + [sys.executable, "-m", "pip", "install", f"eth-ape{spec}", "--quiet"] + ) + if completed_process.returncode != 0: + message = "Update failed" + if output := completed_process.stdout: + message = f"{message}: {output.decode('utf8')}" + + logger.error(message) + sys.exit(completed_process.returncode) + + else: + logger.success("Ape and all plugins have successfully upgraded.") diff --git a/src/ape_plugins/exceptions.py b/src/ape_plugins/exceptions.py new file mode 100644 index 0000000000..ca474eba6e --- /dev/null +++ b/src/ape_plugins/exceptions.py @@ -0,0 +1,26 @@ +from typing import Optional + +from ape.exceptions import ApeException + + +class PluginInstallError(ApeException): + """ + An error to use when installing a plugin fails. + """ + + +class PluginVersionError(PluginInstallError): + """ + An error related to specified plugin version. + """ + + def __init__( + self, operation: str, reason: Optional[str] = None, resolution: Optional[str] = None + ): + message = f"Unable to {operation} plugin." + if reason: + message = f"{message}\nReason: {reason}" + if resolution: + message = f"{message}\nTo resolve: {resolution}" + + super().__init__(message) diff --git a/src/ape_plugins/utils.py b/src/ape_plugins/utils.py index 83e68ce12c..5b01eb3131 100644 --- a/src/ape_plugins/utils.py +++ b/src/ape_plugins/utils.py @@ -2,8 +2,11 @@ import sys from enum import Enum from functools import cached_property -from typing import Iterator, List, Optional, Sequence, Set, Tuple +from typing import Any, Dict, Iterator, List, Optional, Sequence, Set, Tuple +import click +from packaging.specifiers import SpecifierSet +from packaging.version import Version from pydantic import field_validator, model_validator from ape.__modules__ import __modules__ @@ -11,11 +14,80 @@ from ape.plugins import clean_plugin_name from ape.utils import BaseInterfaceModel, get_package_version, github_client from ape.utils.basemodel import BaseModel +from ape.version import version as ape_version_str +from ape_plugins.exceptions import PluginVersionError # Plugins maintained OSS by ApeWorX (and trusted) CORE_PLUGINS = {p for p in __modules__ if p != "ape"} +class ApeVersion: + def __str__(self) -> str: + return str(self.version) + + def __getitem__(self, item): + return str(self)[item] + + @cached_property + def version(self) -> Version: + return Version(ape_version_str.split("dev")[0].rstrip(".")) + + @property + def major(self) -> int: + return self.version.major + + @property + def minor(self) -> int: + return self.version.minor + + @property + def is_pre_one(self) -> bool: + return self.major == 0 + + @cached_property + def version_range(self) -> str: + return ( + f">=0.{self.minor},<0.{self.minor + 1}" + if self.major == 0 + else f">={self.major},<{self.major + 1}" + ) + + @property + def base(self) -> str: + return f"0.{self.minor}.0" if self.major == 0 else f"{self.major}.0.0" + + @cached_property + def next_version_range(self) -> str: + return ( + f">=0.{self.minor + 1},<0.{self.minor + 2}" + if self.version.major == 0 + else f">={self.major + 1},<{self.major + 2}" + ) + + @cached_property + def previous_version_range(self) -> str: + return ( + f">=0.{self.minor - 2},<0.{self.minor - 1}" + if self.version.major == 0 + else f">={self.major - 2},<{self.major - 1}" + ) + + def would_get_downgraded(self, plugin_version_str: str) -> bool: + spec_set = SpecifierSet(plugin_version_str) + for spec in spec_set: + spec_version = Version(spec.version) + if spec.operator in ("==", "<", "<=") and ( + (self.is_pre_one and spec_version.major < ape_version.major) + or (self.is_pre_one and spec_version.minor < ape_version.minor) + ): + return True + + return False + + +ape_version = ApeVersion() + + class PluginType(Enum): CORE = "core" """ @@ -215,8 +287,21 @@ def install_str(self) -> str: # `pip install "ape-plugin>=0.6,<0.7"` version = self.version - if version and ("=" not in version and "<" not in version and ">" not in version): - version = f"=={version}" + if version: + if not any(x in version for x in ("=", "<", ">")): + version = f"=={version}" + + # Validate we are not attempting to install a plugin + # that would change the core-Ape version. + if ape_version.would_get_downgraded(version): + raise PluginVersionError( + "install", "Doing so will downgrade Ape's version.", "Downgrade Ape first." + ) + + elif not version: + # When not specifying the version, use a default one that + # won't dramatically change Ape's version. + version = ape_version.version_range return f"{self.package_name}{version}" if version else self.package_name @@ -262,7 +347,7 @@ def pip_freeze_version(self) -> Optional[str]: verify the update. """ - for package in _pip_freeze_plugins(): + for package in _pip_freeze_plugins(use_cache=False): parts = package.split("==") if len(parts) != 2: continue @@ -298,6 +383,59 @@ def check_installed(self, use_cache: bool = True): ] return self.package_name in ape_packages + def _prepare_install( + self, upgrade: bool = False, skip_confirmation: bool = False + ) -> Optional[Dict[str, Any]]: + # NOTE: Internal and only meant to be called by the CLI. + if self.in_core: + logger.error(f"Cannot install core 'ape' plugin '{self.name}'.") + return None + + elif self.version is not None and upgrade: + logger.error( + f"Cannot use '--upgrade' option when specifying " + f"a version for plugin '{self.name}'." + ) + return None + + # if plugin is installed but not trusted. It must be a third party + elif self.is_third_party: + logger.warning(f"Plugin '{self.name}' is not an trusted plugin.") + + result_handler = ModifyPluginResultHandler(self) + pip_arguments = [sys.executable, "-m", "pip", "install"] + + if upgrade: + logger.info(f"Upgrading '{self.name}' plugin ...") + + # NOTE: A simple --upgrade flag may upgrade the plugin + # to a version outside Core Ape's. Thus, we handle it + # with a version-specifier instead. + pip_arguments.extend( + ("--upgrade", f"{self.package_name}{ape_version.version_range}", "--quiet") + ) + version_before = self.current_version + return { + "args": pip_arguments, + "version_before": version_before, + "result_handler": result_handler, + } + + elif self.can_install and ( + self.is_available + or skip_confirmation + or click.confirm(f"Install the '{self.name}' plugin?") + ): + logger.info(f"Installing '{self}' plugin ...") + pip_arguments.extend((self.install_str, "--quiet")) + return {"args": pip_arguments, "result_handler": result_handler} + + else: + logger.warning( + f"'{self.name}' is already installed. Did you mean to include '--upgrade'?" + ) + return None + class ModifyPluginResultHandler: def __init__(self, plugin: PluginMetadata): @@ -320,7 +458,7 @@ def handle_install_result(self, result: int) -> bool: logger.success(f"Plugin '{plugin_id}' has been installed.") return True - def handle_upgrade_result(self, result, version_before: str) -> bool: + def handle_upgrade_result(self, result: int, version_before: str) -> bool: if result != 0: self._log_errors_occurred("upgrading") return False diff --git a/tests/functional/test_plugins.py b/tests/functional/test_plugins.py index 09319dcf61..c73ce76a1d 100644 --- a/tests/functional/test_plugins.py +++ b/tests/functional/test_plugins.py @@ -3,6 +3,7 @@ import pytest +from ape_plugins.exceptions import PluginVersionError from ape_plugins.utils import ( ApePluginsRepr, ModifyPluginResultHandler, @@ -11,13 +12,19 @@ PluginMetadataList, PluginType, _PipFreeze, + ape_version, ) CORE_PLUGINS = ("run",) AVAILABLE_PLUGINS = ("available", "installed") INSTALLED_PLUGINS = ("installed", "thirdparty") THIRD_PARTY = ("thirdparty",) -VERSION = "0.7.0" + + +mark_specifiers_less_than_ape = pytest.mark.parametrize( + "specifier", + (f"<{ape_version[0]}", f">0.1,<{ape_version[0]}", f"==0.{int(ape_version[2]) - 1}"), +) def get_pip_freeze_output(version: str): @@ -44,18 +51,18 @@ def plugin_test_env(mocker, mock_pip_freeze): # Used when testing PipFreeze object itself but also extra avoids # actually calling out pip ever in tests. - mock_pip_freeze(VERSION) + mock_pip_freeze(ape_version.base) # Prevent requiring plugins to be installed. installed_mock = mocker.patch(f"{root}._pip_freeze_plugins") installed_mock.return_value = { f"ape-{INSTALLED_PLUGINS[0]}", - f"ape-{INSTALLED_PLUGINS[1]}=={VERSION}", + f"ape-{INSTALLED_PLUGINS[1]}=={ape_version.base}", } # Prevent version lookups. version_mock = mocker.patch(f"{root}.get_package_version") - version_mock.return_value = VERSION + version_mock.return_value = ape_version.base @pytest.fixture @@ -95,11 +102,14 @@ def test_names(self, name): def test_model_validator_when_version_included_with_name(self): # This allows parsing requirements files easier - metadata = PluginMetadata(name="ape-foo-bar==0.7.0") + metadata = PluginMetadata(name=f"ape-foo-bar==0.{ape_version.minor}.0") assert metadata.name == "foo-bar" - assert metadata.version == "==0.7.0" + assert metadata.version == f"==0.{ape_version.minor}.0" - @pytest.mark.parametrize("version", ("0.7.0", "v0.7.0", "0.7.0a123")) + @pytest.mark.parametrize( + "version", + (f"0.{ape_version.minor}.0", f"v0.{ape_version.minor}.0", f"0.{ape_version.minor}.0a123"), + ) def test_version(self, version): metadata = PluginMetadata(name="foo", version=version) assert metadata.version == version @@ -107,22 +117,25 @@ def test_version(self, version): def test_install_str_without_version(self): metadata = PluginMetadata(name="foo-bar") actual = metadata.install_str - assert actual == "ape-foo-bar" + expected_version = f">=0.{ape_version.minor},<0.{ape_version.minor + 1}" + assert actual == f"ape-foo-bar{expected_version}" def test_install_str_with_version(self): - metadata = PluginMetadata(name="foo-bar", version="0.7.0") + metadata = PluginMetadata(name="foo-bar", version=f"0.{ape_version.minor}.0") actual = metadata.install_str - assert actual == "ape-foo-bar==0.7.0" + assert actual == f"ape-foo-bar==0.{ape_version.minor}.0" def test_install_str_with_complex_constraint(self): - metadata = PluginMetadata(name="foo", version=">=0.7.0,<0.8.0") + metadata = PluginMetadata( + name="foo", version=f">=0.{ape_version.minor}.0,<0.{ape_version.minor + 1}.0" + ) actual = metadata.install_str - assert actual == "ape-foo>=0.7.0,<0.8.0" + assert actual == f"ape-foo>=0.{ape_version.minor}.0,<0.{ape_version.minor + 1}.0" def test_install_str_with_complex_constraint_in_name(self): - metadata = PluginMetadata(name="foo>=0.7.0,<0.8.0") + metadata = PluginMetadata(name=f"foo>=0.{ape_version.minor}.0,<0.{ape_version.minor + 1}.0") actual = metadata.install_str - assert actual == "ape-foo>=0.7.0,<0.8.0" + assert actual == f"ape-foo>=0.{ape_version.minor}.0,<0.{ape_version.minor + 1}.0" def test_install_str_when_using_git_remote(self): url = "git+https://example.com/ape-foo/branch" @@ -142,6 +155,48 @@ def test_is_available(self): metadata = PluginMetadata(name="foobar") assert not metadata.is_available + def test_prepare_install(self): + metadata = PluginMetadata(name=list(AVAILABLE_PLUGINS)[0]) + actual = metadata._prepare_install(skip_confirmation=True) + assert actual is not None + arguments = actual.get("args", []) + expected = [ + "-m", + "pip", + "install", + f"ape-available>=0.{ape_version.minor},<0.{ape_version.minor + 1}", + "--quiet", + ] + assert arguments[0].endswith("python") + assert arguments[1:] == expected + + def test_prepare_install_upgrade(self): + metadata = PluginMetadata(name=list(AVAILABLE_PLUGINS)[0]) + actual = metadata._prepare_install(upgrade=True, skip_confirmation=True) + assert actual is not None + arguments = actual.get("args", []) + expected = [ + "-m", + "pip", + "install", + "--upgrade", + f"ape-available>=0.{ape_version.minor},<0.{ape_version.minor + 1}", + "--quiet", + ] + assert arguments[0].endswith("python") + assert arguments[1:] == expected + + @mark_specifiers_less_than_ape + def test_prepare_install_version_smaller_than_ape(self, specifier, ape_caplog): + metadata = PluginMetadata(name=list(AVAILABLE_PLUGINS)[0], version=specifier) + expected = ( + r"Unable to install plugin\.\n" + r"Reason: Doing so will downgrade Ape's version\.\n" + r"To resolve: Downgrade Ape first\." + ) + with pytest.raises(PluginVersionError, match=expected): + metadata._prepare_install(skip_confirmation=True) + class TestApePluginsRepr: def test_str(self, plugin_metadata): @@ -149,10 +204,10 @@ def test_str(self, plugin_metadata): actual = str(representation) expected = f""" Installed Plugins - installed {VERSION} + installed {ape_version.base} Third-party Plugins - thirdparty {VERSION} + thirdparty {ape_version.base} """ assert actual == expected.strip() @@ -164,10 +219,10 @@ def test_str_all_types(self, plugin_metadata): run Installed Plugins - installed {VERSION} + installed {ape_version.base} Third-party Plugins - thirdparty {VERSION} + thirdparty {ape_version.base} Available Plugins available @@ -213,22 +268,25 @@ def test_repr_when_exception(self, mocker): def test_pip_freeze_includes_version_when_available(): pip_freeze = _PipFreeze() actual = pip_freeze.get_plugins() - expected = {f"ape-{INSTALLED_PLUGINS[0]}", f"ape-{THIRD_PARTY[0]}==0.7.0"} + expected = {f"ape-{INSTALLED_PLUGINS[0]}", f"ape-{THIRD_PARTY[0]}==0.{ape_version.minor}.0"} assert actual == expected def test_handle_upgrade_result_when_upgrading_to_same_version(caplog, logger): - # NOTE: pip freeze mock also returns version 0.7.0 (so upgrade to same). + # NOTE: pip freeze mock also returns version 0.{minor}.0 (so upgrade to same). logger.set_level("INFO") # Required for test. plugin = PluginMetadata(name=THIRD_PARTY[0]) handler = ModifyPluginResultHandler(plugin) - handler.handle_upgrade_result(0, "0.7.0") + handler.handle_upgrade_result(0, f"0.{ape_version.minor}.0") if records := caplog.records: - assert f"'{THIRD_PARTY[0]}' already has version '0.7.0'" in records[-1].message + assert ( + f"'{THIRD_PARTY[0]}' already has version '0.{ape_version.minor}.0'" + in records[-1].message + ) else: version_at_end = plugin.pip_freeze_version pytest.fail( - "Missing logs when upgrading to same version 0.7.0. " + f"Missing logs when upgrading to same version 0.{ape_version.minor}.0. " f"pip_freeze_version={version_at_end}" ) @@ -237,10 +295,31 @@ def test_handle_upgrade_result_when_no_pip_freeze_version_does_not_log(caplog): plugin_no_version = INSTALLED_PLUGINS[0] # Version not in pip-freeze plugin = PluginMetadata(name=plugin_no_version) handler = ModifyPluginResultHandler(plugin) - handler.handle_upgrade_result(0, "0.7.0") + handler.handle_upgrade_result(0, f"0.{ape_version.minor}.0") log_parts = ("already has version", "already up to date") messages = [x.message for x in caplog.records] for message in messages: for pt in log_parts: assert pt not in message + + +class TestApeVersion: + def test_version_range(self): + actual = ape_version.version_range + expected = f">=0.{ape_version[2]},<0.{int(ape_version[2]) + 1}" + assert actual == expected + + def test_next_version_range(self): + actual = ape_version.next_version_range + expected = f">=0.{int(ape_version[2]) + 1},<0.{int(ape_version[2]) + 2}" + assert actual == expected + + def test_previous_version_range(self): + actual = ape_version.previous_version_range + expected = f">=0.{int(ape_version[2]) - 2},<0.{int(ape_version[2]) - 1}" + assert actual == expected + + @mark_specifiers_less_than_ape + def test_would_be_downgraded(self, specifier): + assert ape_version.would_get_downgraded(specifier)