Skip to content

Commit

Permalink
feat: added remove option for ape pm [APE-1315] (#1621)
Browse files Browse the repository at this point in the history
Co-authored-by: antazoey <[email protected]>
Co-authored-by: NotPeopling2day <[email protected]>
  • Loading branch information
3 people authored Sep 6, 2023
1 parent 26d22cc commit 735e003
Show file tree
Hide file tree
Showing 9 changed files with 299 additions and 17 deletions.
23 changes: 23 additions & 0 deletions docs/userguides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/ape/managers/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down
5 changes: 2 additions & 3 deletions src/ape/managers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
58 changes: 57 additions & 1 deletion src/ape/managers/project/dependency.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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]] = {
Expand All @@ -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):
"""
Expand Down
42 changes: 42 additions & 0 deletions src/ape/managers/project/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 78 additions & 1 deletion src/ape_pm/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand All @@ -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 <PackageName> "1.0.0" "2.0.0"\n
- Prompt to choose versions: ape pm remove <PackageName>\n
- Remove all versions: ape pm remove <PackageName> -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)
Expand Down
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
11 changes: 3 additions & 8 deletions tests/functional/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading

0 comments on commit 735e003

Please sign in to comment.