diff --git a/docs/userguides/dependencies.md b/docs/userguides/dependencies.md index 7d9dfebec5..17192d025c 100644 --- a/docs/userguides/dependencies.md +++ b/docs/userguides/dependencies.md @@ -131,6 +131,29 @@ For `npm` dependencies, you use an `npm:` prefix. For local dependencies, you give it a path to the local dependency. `--version` is not required when using a local dependency. +### remove + +Remove previously installed packages using the `remove` command: + +```shell +ape pm remove OpenZeppelin +``` + +If there is a single version installed, the command will remove the single version. +If multiple versions are installed, pass additional arguments specifying the version(s) to be removed: + +```shell +ape pm remove OpenZeppelin 4.5.0 4.6.0 +``` + +To skip the confirmation prompts, use the `--yes` flag (abbreviated as `-y`): + +```shell +ape pm remove OpenZeppelin all --yes +``` + +**NOTE**: Additionally, use the `all` special version key to delete all versions. + ### compile Dependencies are not compiled when they are installed. diff --git a/src/ape/managers/chain.py b/src/ape/managers/chain.py index c449feb43f..fd27243fd3 100644 --- a/src/ape/managers/chain.py +++ b/src/ape/managers/chain.py @@ -1154,7 +1154,7 @@ def instance_at( address: Union[str, AddressType], contract_type: Optional[ContractType] = None, txn_hash: Optional[str] = None, - abi: Optional[Union[Union[List[ABI], Dict], str, Path]] = None, + abi: Optional[Union[List[ABI], Dict, str, Path]] = None, ) -> ContractInstance: """ Get a contract at the given address. If the contract type of the contract is known, @@ -1174,7 +1174,7 @@ def instance_at( in case it is not already known. txn_hash (Optional[str]): The hash of the transaction responsible for deploying the contract, if known. Useful for publishing. Defaults to ``None``. - abi (Optional[Union[Union[List[ABI], Dict], str, Path]]): Use an ABI str, dict, path, + abi (Optional[Union[List[ABI], Dict, str, Path]]): Use an ABI str, dict, path, or ethpm models to create a contract instance class. Returns: diff --git a/src/ape/managers/config.py b/src/ape/managers/config.py index 0efcf1745e..531c9b3b57 100644 --- a/src/ape/managers/config.py +++ b/src/ape/managers/config.py @@ -124,9 +124,8 @@ def check_config_for_extra_fields(cls, values: Dict[str, Any]) -> Dict[str, Any] @property def packages_folder(self) -> Path: - path = self.DATA_FOLDER / "packages" - path.mkdir(parents=True, exist_ok=True) - return path + self.dependency_manager.packages_folder.mkdir(parents=True, exist_ok=True) + return self.dependency_manager.packages_folder @property def _plugin_configs(self) -> Dict[str, PluginConfig]: diff --git a/src/ape/managers/project/dependency.py b/src/ape/managers/project/dependency.py index 410c10853e..401dea90a8 100644 --- a/src/ape/managers/project/dependency.py +++ b/src/ape/managers/project/dependency.py @@ -1,8 +1,9 @@ import json import os +import shutil import tempfile from pathlib import Path -from typing import Dict, Iterable, Optional, Type +from typing import Dict, Iterable, List, Optional, Type from ethpm_types import PackageManifest from ethpm_types.utils import AnyUrl @@ -21,6 +22,10 @@ class DependencyManager(ManagerAccessMixin): def __init__(self, data_folder: Path): self.DATA_FOLDER = data_folder + @property + def packages_folder(self) -> Path: + return self.DATA_FOLDER / "packages" + @cached_property def dependency_types(self) -> Dict[str, Type[DependencyAPI]]: dependency_classes: Dict[str, Type[DependencyAPI]] = { @@ -43,6 +48,57 @@ def decode_dependency(self, config_dependency_data: Dict) -> DependencyAPI: dep_id = config_dependency_data.get("name", json.dumps(config_dependency_data)) raise ProjectError(f"No installed dependency API that supports '{dep_id}'.") + def get_versions(self, name: str) -> List[Path]: + path = self.packages_folder / name + if not path.is_dir(): + logger.warning("Dependency not installed.") + return [] + + return [x for x in path.iterdir() if x.is_dir()] + + def remove_dependency(self, name: str, versions: Optional[List[str]] = None): + versions = versions or [] + available_versions = self.get_versions(name) + if not available_versions: + # Clean up (user was already warned). + if (self.packages_folder / name).is_dir(): + shutil.rmtree(self.packages_folder / name, ignore_errors=True) + + return + + # Use single version if there is one and wasn't given anything. + versions = ( + [x.name for x in available_versions] + if not versions and len(available_versions) == 1 + else versions + ) + if not versions: + raise ProjectError("Please specify versions to remove.") + + path = self.packages_folder / name + for version in versions: + if (path / version).is_dir(): + version_key = version + elif (path / f"v{version}").is_dir(): + version_key = f"v{version}" + else: + raise ProjectError(f"Version '{version}' of package '{name}' is not installed.") + + path = self.packages_folder / name / version_key + if not path.is_dir(): + available_versions_str = ", ".join([x.name for x in available_versions]) + raise ProjectError( + f"Version '{version}' not found in dependency {name}. " + f"Available versions: {available_versions_str}" + ) + + shutil.rmtree(path) + + # If there are no more versions, delete the whole package directory. + remaining_versions = self.get_versions(name) + if not remaining_versions: + shutil.rmtree(self.packages_folder / name, ignore_errors=True) + class GithubDependency(DependencyAPI): """ diff --git a/src/ape/managers/project/manager.py b/src/ape/managers/project/manager.py index 275f24fe27..41945e20c8 100644 --- a/src/ape/managers/project/manager.py +++ b/src/ape/managers/project/manager.py @@ -712,6 +712,48 @@ def load_dependencies(self, use_cache: bool = True) -> Dict[str, Dict[str, Depen return self._cached_dependencies.get(project_id, {}) + def remove_dependency(self, dependency_name: str, versions: Optional[List[str]] = None): + project_id = str(self.path) + + try: + self.dependency_manager.remove_dependency(dependency_name, versions=versions) + finally: + # Delete locally. + if dependency_name in self._cached_dependencies.get(project_id, {}): + versions_available = self.dependency_manager.get_versions(dependency_name) + if not versions and len(versions_available) == 1: + versions = [x.name for x in versions_available] + elif not versions: + raise ProjectError("`versions` kwarg required.") + + local_versions = self._cached_dependencies.get(project_id, {}).get( + dependency_name, {} + ) + for version in versions: + if version in local_versions: + version_key = version + elif f"v{version}" in local_versions: + version_key = f"v{version}" + else: + logger.warning(f"Version '{version}' not installed.") + continue + + del self._cached_dependencies[project_id][dependency_name][version_key] + + # Clean ups. + if ( + project_id in self._cached_dependencies + and dependency_name in self._cached_dependencies[project_id] + and not self._cached_dependencies[project_id][dependency_name] + ): + del self._cached_dependencies[project_id][dependency_name] + + if ( + project_id in self._cached_dependencies + and not self._cached_dependencies[project_id] + ): + del self._cached_dependencies[project_id] + def track_deployment(self, contract: ContractInstance): """ Indicate that a contract deployment should be included in the package manifest diff --git a/src/ape_pm/_cli.py b/src/ape_pm/_cli.py index 124d0fe43c..4fd9bb85ee 100644 --- a/src/ape_pm/_cli.py +++ b/src/ape_pm/_cli.py @@ -83,6 +83,9 @@ def _package_callback(ctx, param, value): # Is an NPM style dependency return {"npm:": value[4:]} + elif value == ".": + return value + # Check if is a local package. try: path = Path(value).absolute() @@ -121,7 +124,8 @@ def install(cli_ctx, package, name, version, ref, force): """ log_name = None - if not package: + + if not package or package == ".": # `ape pm install`: Load all dependencies from current package. cli_ctx.project_manager.load_dependencies(use_cache=not force) @@ -148,6 +152,79 @@ def install(cli_ctx, package, name, version, ref, force): cli_ctx.logger.success("All project packages installed.") +@cli.command() +@ape_cli_context() +@click.argument("package", nargs=1, required=True) +@click.argument("versions", nargs=-1, required=False) +@click.option( + "-y", "--yes", is_flag=True, help="Automatically confirm the removal of the package(s)" +) +def remove(cli_ctx, package, versions, yes): + """ + Remove a package + + This command removes a package from the installed packages. + + If specific versions are provided, only those versions of the package will be + removed. If no versions are provided, the command will prompt you to choose + versions to remove. You can also choose to remove all versions of the package. + + Examples:\n + - Remove specific versions: ape pm remove "1.0.0" "2.0.0"\n + - Prompt to choose versions: ape pm remove \n + - Remove all versions: ape pm remove -y + """ + package_dir = cli_ctx.dependency_manager.DATA_FOLDER / "packages" / package + if not package_dir.is_dir(): + cli_ctx.abort(f"Package '{package}' is not installed.") + + # Remove multiple versions if no version is specified + versions_to_remove = versions if versions else [] + if len(versions_to_remove) == 1 and versions_to_remove[0] == "all": + versions_to_remove = [d.name for d in package_dir.iterdir() if d.is_dir()] + + elif not versions_to_remove: + available_versions = [d.name for d in package_dir.iterdir() if d.is_dir()] + if not available_versions: + cli_ctx.abort(f"No installed versions of package '{package}' found.") + + # If there is only one version, use that. + if len(available_versions) == 1 or yes: + versions_to_remove = available_versions + + else: + version_prompt = ( + f"Which versions of package '{package}' do you want to remove? " + f"{available_versions} (separate multiple versions with comma, or 'all')" + ) + versions_input = click.prompt(version_prompt) + if versions_input.strip() == "all": + versions_to_remove = available_versions + else: + versions_to_remove = [v.strip() for v in versions_input.split(",") if v.strip()] + + # Prevents a double-prompt. + yes = True + + if not versions_to_remove: + cli_ctx.logger.info("No versions selected for removal.") + return + + # Remove all the versions specified + for version in versions_to_remove: + if not (package_dir / version).is_dir() and not (package_dir / f"v{version}").is_dir(): + cli_ctx.logger.warning( + f"Version '{version}' of package '{package_dir.name}' is not installed." + ) + continue + + elif yes or click.confirm( + f"Are you sure you want to remove version '{version}' of package '{package}'?" + ): + cli_ctx.project_manager.remove_dependency(package_dir.name, versions=[version]) + cli_ctx.logger.success(f"Version '{version}' of package '{package_dir.name}' removed.") + + @cli.command() @ape_cli_context() @click.argument("name", nargs=1, required=False) diff --git a/tests/conftest.py b/tests/conftest.py index 7567bda8b3..af0b9eb79b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ # NOTE: Ensure that we don't use local paths for these DATA_FOLDER = Path(mkdtemp()).resolve() ape.config.DATA_FOLDER = DATA_FOLDER +ape.config.dependency_manager.DATA_FOLDER = DATA_FOLDER PROJECT_FOLDER = Path(mkdtemp()).resolve() ape.config.PROJECT_FOLDER = PROJECT_FOLDER @@ -297,8 +298,10 @@ def func(data: Optional[Dict] = None): def empty_data_folder(): current_data_folder = ape.config.DATA_FOLDER ape.config.DATA_FOLDER = Path(mkdtemp()).resolve() + ape.config.dependency_manager.DATA_FOLDER = ape.config.DATA_FOLDER yield ape.config.DATA_FOLDER = current_data_folder + ape.config.dependency_manager.DATA_FOLDER = ape.config.DATA_FOLDER @pytest.fixture diff --git a/tests/functional/test_query.py b/tests/functional/test_query.py index 180a18dae6..4bbb5885e7 100644 --- a/tests/functional/test_query.py +++ b/tests/functional/test_query.py @@ -86,22 +86,17 @@ def test_column_validation(eth_tester_provider, caplog): validate_and_expand_columns(["numbr"], Model) expected = "Unrecognized field(s) 'numbr', must be one of 'number, timestamp'." - assert exc_info.value.args[0] == expected - caplog.clear() + assert exc_info.value.args[-1] == expected with caplog.at_level(logging.WARNING): validate_and_expand_columns(["numbr", "timestamp"], Model) - assert len(caplog.records) == 1 - assert expected in caplog.records[0].msg - caplog.clear() + assert expected in caplog.records[-1].msg with caplog.at_level(logging.WARNING): validate_and_expand_columns(["number", "timestamp", "number"], Model) - assert len(caplog.records) == 1 - assert "Duplicate fields in ['number', 'timestamp', 'number']" in caplog.records[0].msg - caplog.clear() + assert "Duplicate fields in ['number', 'timestamp', 'number']" in caplog.records[-1].msg def test_specify_engine(chain, eth_tester_provider): diff --git a/tests/integration/cli/test_pm.py b/tests/integration/cli/test_pm.py index ac9cc82a85..de5bbcc3a0 100644 --- a/tests/integration/cli/test_pm.py +++ b/tests/integration/cli/test_pm.py @@ -9,7 +9,7 @@ def test_install_path_not_exists(ape_cli, runner): path = "path/to/nowhere" result = runner.invoke(ape_cli, ["pm", "install", path]) - assert result.exit_code != 0 + assert result.exit_code != 0, result.output assert EXPECTED_FAIL_MESSAGE.format(path) in result.output @@ -86,7 +86,7 @@ def test_compile_package_not_exists(ape_cli, runner): name = "NOT_EXISTS" result = runner.invoke(ape_cli, ["pm", "compile", name]) expected = f"Dependency '{name}' unknown. Is it installed?" - assert result.exit_code != 0 + assert result.exit_code != 0, result.output assert expected in result.output @@ -103,3 +103,90 @@ def test_compile_dependency(ape_cli, runner, project): result = runner.invoke(ape_cli, ["pm", "compile", name]) assert result.exit_code == 0, result.output assert f"Package '{name}' compiled." in result.output + + +@skip_projects_except("only-dependencies") +def test_remove(ape_cli, runner, project): + package_name = "dependency-in-project-only" + + # Install packages + runner.invoke(ape_cli, ["pm", "install", ".", "--force"]) + + result = runner.invoke(ape_cli, ["pm", "remove", package_name], input="y\n") + expected_message = f"Version 'local' of package '{package_name}' removed." + assert result.exit_code == 0, result.output + assert expected_message in result.output + + +@skip_projects_except("only-dependencies") +def test_remove_not_exists(ape_cli, runner, project): + package_name = "_this_does_not_exist_" + result = runner.invoke(ape_cli, ["pm", "remove", package_name]) + expected_message = f"ERROR: Package '{package_name}' is not installed." + assert result.exit_code != 0, result.output + assert expected_message in result.output + + +@skip_projects_except("only-dependencies") +def test_remove_specific_version(ape_cli, runner, project): + package_name = "dependency-in-project-only" + version = "local" + + # Install packages + runner.invoke(ape_cli, ["pm", "install", ".", "--force"]) + + result = runner.invoke(ape_cli, ["pm", "remove", package_name], input="y\n") + expected_message = f"Version '{version}' of package '{package_name}' removed." + assert result.exit_code == 0, result.output + assert expected_message in result.output + + +@skip_projects_except("only-dependencies") +def test_remove_all_versions_with_y(ape_cli, runner): + # Install packages + runner.invoke(ape_cli, ["pm", "install", ".", "--force"]) + + package_name = "dependency-in-project-only" + result = runner.invoke(ape_cli, ["pm", "remove", package_name, "-y"]) + expected_message = f"SUCCESS: Version 'local' of package '{package_name}' removed." + assert result.exit_code == 0, result.output + assert expected_message in result.output + + +@skip_projects_except("only-dependencies") +def test_remove_specific_version_with_y(ape_cli, runner): + # Install packages + runner.invoke(ape_cli, ["pm", "install", ".", "--force"]) + + package_name = "dependency-in-project-only" + version = "local" + result = runner.invoke(ape_cli, ["pm", "remove", package_name, version, "-y"]) + expected_message = f"Version '{version}' of package '{package_name}' removed." + assert result.exit_code == 0, result.output + assert expected_message in result.output + + +@skip_projects_except("only-dependencies") +def test_remove_cancel(ape_cli, runner): + # Install packages + runner.invoke(ape_cli, ["pm", "install", ".", "--force"]) + + package_name = "dependency-in-project-only" + version = "local" + result = runner.invoke(ape_cli, ["pm", "remove", package_name, version], input="n\n") + assert result.exit_code == 0, result.output + expected_message = f"Version '{version}' of package '{package_name}' removed." + assert expected_message not in result.output + + +@skip_projects_except("only-dependencies") +def test_remove_invalid_version(ape_cli, runner): + # Install packages + runner.invoke(ape_cli, ["pm", "install", ".", "--force"]) + + package_name = "dependency-in-project-only" + invalid_version = "0.0.0" + result = runner.invoke(ape_cli, ["pm", "remove", package_name, invalid_version]) + + expected_message = f"Version '{invalid_version}' of package '{package_name}' is not installed." + assert expected_message in result.output