Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: avoid installing dependencies during pm list command #2288

Merged
merged 5 commits into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading