Skip to content

Commit

Permalink
feat: avoid installing dependencies during pm list command (#2288)
Browse files Browse the repository at this point in the history
  • Loading branch information
antazoey committed Sep 17, 2024
1 parent 385e77c commit c3c6b3b
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 28 deletions.
4 changes: 2 additions & 2 deletions docs/userguides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ ape pm list
You should see information like:

```shell
NAME VERSION COMPILED
openzeppelin 4.9.3 -
NAME VERSION INSTALLED COMPILED
OpenZeppelin/openzeppelin-contracts 4.9.3 True False
```

### install
Expand Down
78 changes: 63 additions & 15 deletions src/ape/managers/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,16 +626,42 @@ def _cache(self) -> "PackagesCache":

@property
def project_path(self) -> Path:
"""
The path to the dependency's project root. When installing, this
is where the project files go.
"""
return self._cache.get_project_path(self.package_id, self.version)

@property
def manifest_path(self) -> Path:
"""
The path to the dependency's manifest. When compiling, the artifacts go here.
"""
return self._cache.get_manifest_path(self.package_id, self.version)

@property
def api_path(self) -> Path:
"""
The path to the dependency's API data-file. This data is necessary
for managing the install of the dependency.
"""
return self._cache.get_api_path(self.package_id, self.version)

@property
def installed(self) -> bool:
"""
``True`` when a project is available. Note: Installed does not mean
the dependency is compiled!
"""
if self._installation is not None:
return True

elif self.project_path.is_dir():
if any(x for x in self.project_path.iterdir() if not x.name.startswith(".")):
return True

return False

@property
def uri(self) -> str:
"""
Expand Down Expand Up @@ -667,7 +693,11 @@ def install(

return self._installation

elif (not self.project_path.is_dir()) or not use_cache:
elif (
not self.project_path.is_dir()
or len([x for x in self.project_path.iterdir() if not x.name.startswith(".")]) == 0
or not use_cache
):
unpacked = False
if use_cache and self.manifest_path.is_file():
# Attempt using sources from manifest. This may happen
Expand Down Expand Up @@ -749,7 +779,7 @@ def install(
# Also, install dependencies of dependencies, if fetching for the
# first time.
if did_fetch:
spec = project.dependencies._get_specified(use_cache=use_cache)
spec = project.dependencies.get_project_dependencies(use_cache=use_cache)
list(spec)

return project
Expand Down Expand Up @@ -1083,7 +1113,7 @@ def __getitem__(self, name: str) -> DependencyVersionMap:
result = DependencyVersionMap(name)

# Always ensure the specified are included, even if not yet installed.
if versions := {d.version: d.project for d in self._get_specified(name=name)}:
if versions := {d.version: d.project for d in self.get_project_dependencies(name=name)}:
result.extend(versions)

# Add remaining installed versions.
Expand Down Expand Up @@ -1141,15 +1171,32 @@ def specified(self) -> Iterator[Dependency]:
"""
All dependencies specified in the config.
"""
yield from self._get_specified()
yield from self.get_project_dependencies()

def _get_specified(
def get_project_dependencies(
self,
use_cache: bool = True,
config_override: Optional[dict] = None,
name: Optional[str] = None,
version: Optional[str] = None,
allow_install: bool = True,
) -> Iterator[Dependency]:
"""
Get dependencies specified in the project's ``ape-config.yaml`` file.
Args:
use_cache (bool): Set to ``False`` to force-reinstall dependencies.
Defaults to ``True``. Does not work with ``allow_install=False``.
config_override (Optional[dict]): Override shared configuration for each dependency.
name (Optional[str]): Optionally only get dependencies with a certain name.
version (Optional[str]): Optionally only get dependencies with certain version.
allow_install (bool): Set to ``False`` to not allow installing uninstalled
specified dependencies.
Returns:
Iterator[:class:`~ape.managers.project.Dependency`]
"""

for api in self.config_apis:
if (name is not None and api.name != name and api.package_id != name) or (
version is not None and api.version_id != version
Expand All @@ -1159,14 +1206,15 @@ def _get_specified(
# Ensure the dependency API data is known.
dependency = self.add(api)

try:
dependency.install(use_cache=use_cache, config_override=config_override)
except ProjectError:
# This dependency has issues. Let's wait to until the user
# actually requests something before failing, and
# yield an uninstalled version of the specified dependency for
# them to fix.
pass
if allow_install:
try:
dependency.install(use_cache=use_cache, config_override=config_override)
except ProjectError:
# This dependency has issues. Let's wait to until the user
# actually requests something before failing, and
# yield an uninstalled version of the specified dependency for
# them to fix.
pass

yield dependency

Expand Down Expand Up @@ -1284,7 +1332,7 @@ def get_versions(self, name: str) -> Iterator[Dependency]:
"""
# First, check specified. Note: installs if needed.
versions_yielded = set()
for dependency in self._get_specified(name=name):
for dependency in self.get_project_dependencies(name=name):
if dependency.version in versions_yielded:
continue

Expand Down Expand Up @@ -1454,7 +1502,7 @@ def install(self, **dependency: Any) -> Union[Dependency, list[Dependency]]:
result: list[Dependency] = []

# Log the errors as they happen but don't crash the full install.
for dep in self._get_specified(use_cache=use_cache):
for dep in self.get_project_dependencies(use_cache=use_cache):
result.append(dep)

return result
Expand Down
33 changes: 24 additions & 9 deletions src/ape_pm/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,21 @@ def _list(cli_ctx, list_all):

dm = cli_ctx.dependency_manager
packages = []
dependencies = [*list(dm.specified)]
dependencies = [*list(dm.get_project_dependencies(use_cache=True, allow_install=False))]
if list_all:
dependencies = list({*dependencies, *dm.installed})

for dependency in dependencies:
try:
is_compiled = dependency.project.is_compiled
except ProjectError:
# Project may not even be installed right.
if dependency.installed:
is_installed = True
try:
is_compiled = dependency.project.is_compiled
except ProjectError:
# Project may not even be installed right.
is_compiled = False

else:
is_installed = False
is_compiled = False

# For local dependencies, use the short name.
Expand All @@ -50,6 +56,7 @@ def _list(cli_ctx, list_all):
item = {
"name": name,
"version": dependency.version,
"installed": is_installed,
"compiled": is_compiled,
}
packages.append(item)
Expand All @@ -61,23 +68,31 @@ def _list(cli_ctx, list_all):
# Output gathered packages.
longest_name = max([4, *[len(p["name"]) for p in packages]])
longest_version = max([7, *[len(p["version"]) for p in packages]])
longest_installed = max([9, *[len(f"{p['installed']}") for p in packages]])
tab = " "

header_name_space = ((longest_name - len("NAME")) + 2) * " "
version_name_space = ((longest_version - len("VERSION")) + 2) * " "

def get_package_str(_package) -> str:
name = click.style(_package["name"], bold=True)
dep_name = click.style(_package["name"], bold=True)
version = _package["version"]
installed = (
click.style(_package["installed"], fg="green") if _package.get("installed") else "False"
)
compiled = (
click.style(_package["compiled"], fg="green") if _package.get("compiled") else "-"
click.style(_package["compiled"], fg="green") if _package.get("compiled") else "False"
)
spacing_name = ((longest_name - len(_package["name"])) + len(tab)) * " "
spacing_version = ((longest_version - len(version)) + len(tab)) * " "
return f"{name}{spacing_name}{version}{spacing_version + compiled}"
spacing_installed = ((longest_installed - len(f"{_package['installed']}")) + len(tab)) * " "
return (
f"{dep_name}{spacing_name}{version}{spacing_version}"
f"{installed}{spacing_installed}{compiled}"
)

def rows():
yield f"NAME{header_name_space}VERSION{version_name_space}COMPILED\n"
yield f"NAME{header_name_space}VERSION{version_name_space}INSTALLED COMPILED\n"
for _package in sorted(packages, key=lambda p: f"{p['name']}{p['version']}"):
yield f"{get_package_str(_package)}\n"

Expand Down
8 changes: 7 additions & 1 deletion tests/functional/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ def test_install(project, mocker):
contracts_path.mkdir(exist_ok=True, parents=True)
(contracts_path / "contract.json").write_text('{"abi": []}', encoding="utf8")
data = {"name": "FooBar", "local": f"{tmp_project.path}"}
get_spec_spy = mocker.spy(tmp_project.dependencies, "_get_specified")
get_spec_spy = mocker.spy(tmp_project.dependencies, "get_project_dependencies")
install_dep_spy = mocker.spy(tmp_project.dependencies, "install_dependency")

# Show can install from DependencyManager.
Expand Down Expand Up @@ -649,6 +649,12 @@ def test_manifest_path(self, dependency, data_folder):
expected = data_folder / "packages" / "manifests" / name / "1_0_0.json"
assert actual == expected

def test_installed(self, dependency):
dependency.uninstall()
assert not dependency.installed
dependency.install()
assert dependency.installed

def test_compile(self, project):
with create_tempdir() as path:
api = LocalDependency(local=path, name="ooga", version="1.0.0")
Expand Down
25 changes: 24 additions & 1 deletion tests/integration/cli/test_pm.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,29 @@ def test_uninstall_cancel(pm_runner, integ_project):
def test_list(pm_runner, integ_project):
pm_runner.project = integ_project
package_name = "dependency-in-project-only"
dependency = integ_project.dependencies.get_dependency(package_name, "local")

# Ensure we are not installed.
dependency.uninstall()

result = pm_runner.invoke("list")
assert result.exit_code == 0, result.output
assert package_name in result.output

# NOTE: Not using f-str here so we can see the spacing.
expected = """
NAME VERSION INSTALLED COMPILED
dependency-in-project-only local False False
""".strip()
assert expected in result.output

# Install and show it change.
dependency = integ_project.dependencies.get_dependency(package_name, "local")
dependency.install()

expected = """
NAME VERSION INSTALLED COMPILED
dependency-in-project-only local True False
""".strip()
result = pm_runner.invoke("list")
assert result.exit_code == 0, result.output
assert expected in result.output

0 comments on commit c3c6b3b

Please sign in to comment.