From c3c6b3bd73d9009557e12abe9a020b318bc4015e Mon Sep 17 00:00:00 2001 From: antazoey Date: Tue, 17 Sep 2024 13:54:41 -0500 Subject: [PATCH] feat: avoid installing dependencies during `pm list` command (#2288) --- docs/userguides/dependencies.md | 4 +- src/ape/managers/project.py | 78 +++++++++++++++++++++------ src/ape_pm/_cli.py | 33 ++++++++---- tests/functional/test_dependencies.py | 8 ++- tests/integration/cli/test_pm.py | 25 ++++++++- 5 files changed, 120 insertions(+), 28 deletions(-) diff --git a/docs/userguides/dependencies.md b/docs/userguides/dependencies.md index adee75f67b..9509d59c07 100644 --- a/docs/userguides/dependencies.md +++ b/docs/userguides/dependencies.md @@ -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 diff --git a/src/ape/managers/project.py b/src/ape/managers/project.py index dd9f1f5971..a5f58b6763 100644 --- a/src/ape/managers/project.py +++ b/src/ape/managers/project.py @@ -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: """ @@ -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 @@ -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 @@ -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. @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/src/ape_pm/_cli.py b/src/ape_pm/_cli.py index f3eeba547a..760043c0ae 100644 --- a/src/ape_pm/_cli.py +++ b/src/ape_pm/_cli.py @@ -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. @@ -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) @@ -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" diff --git a/tests/functional/test_dependencies.py b/tests/functional/test_dependencies.py index 1686c40c1c..bfb2a3a0fc 100644 --- a/tests/functional/test_dependencies.py +++ b/tests/functional/test_dependencies.py @@ -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. @@ -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") diff --git a/tests/integration/cli/test_pm.py b/tests/integration/cli/test_pm.py index 5ad62e7908..03f02484b5 100644 --- a/tests/integration/cli/test_pm.py +++ b/tests/integration/cli/test_pm.py @@ -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