diff --git a/docs/userguides/dependencies.md b/docs/userguides/dependencies.md index a4179e4634..5359dfd036 100644 --- a/docs/userguides/dependencies.md +++ b/docs/userguides/dependencies.md @@ -57,6 +57,17 @@ Often times, the `v` prefix is required when using tags. However, if cloning the tag fails, `ape` will retry with a `v` prefix. Bypass the original failing attempt by including a `v` in your dependency config. +### Python + +You can use dependencies to PyPI by using the `python:` keyed dependency type. + +```yaml +dependencies: + - python: snekmate + config_override: + contracts_folder: . +``` + ### Local You can use already-downloaded projects as dependencies by referencing them as local dependencies. diff --git a/docs/userguides/projects.md b/docs/userguides/projects.md index bd7431727e..7eddef55f3 100644 --- a/docs/userguides/projects.md +++ b/docs/userguides/projects.md @@ -55,6 +55,16 @@ project = Project.from_manifest("path/to/manifest.json") _ = project.MyContract # Do anything you can do to the root-level project. ``` +## Installed Python Projects + +If you have installed a project using `pip` or alike and you wish to reference its project, use the `Project.from_python_library()` class method. + +```python +from ape import Project + +snekmate = Project.from_python_library("snekmate", config_override={"contracts_folder": "."}) +``` + ## Dependencies Use other projects as dependencies in Ape. diff --git a/src/ape/managers/project.py b/src/ape/managers/project.py index e0bd32012c..4c1a82685e 100644 --- a/src/ape/managers/project.py +++ b/src/ape/managers/project.py @@ -34,6 +34,7 @@ create_tempdir, get_all_files_in_directory, get_full_extension, + get_package_path, get_relative_path, in_tempdir, path_match, @@ -1515,7 +1516,35 @@ def from_manifest( Returns: :class:`~ape.managers.project.ProjectManifest` """ - return Project.from_manifest(manifest, config_override=config_override) + config_override = config_override or {} + manifest = _load_manifest(manifest) if isinstance(manifest, (Path, str)) else manifest + return Project(manifest, config_override=config_override) + + @classmethod + def from_python_library( + cls, package_name: str, config_override: Optional[dict] = None + ) -> "LocalProject": + """ + Create an Ape project instance from an installed Python package. + This is useful for when Ape or Vyper projects are published to + pypi. + + Args: + package_name (str): The name of the package's folder that would + appear in site-packages. + config_override (dict | None): Optionally override the configuration + for this project. + + Returns: + :class:`~ape.managers.project.LocalProject` + """ + try: + pkg_path = get_package_path(package_name) + except ValueError as err: + raise ProjectError(str(err)) from err + + # Treat site-package as a local-project. + return LocalProject(pkg_path, config_override=config_override) @classmethod @contextmanager @@ -1581,14 +1610,6 @@ def is_compiled(self) -> bool: """ return (self._manifest.contract_types or None) is not None - @classmethod - def from_manifest( - cls, manifest: Union[PackageManifest, Path, str], config_override: Optional[dict] = None - ) -> "Project": - config_override = config_override or {} - manifest = _load_manifest(manifest) if isinstance(manifest, (Path, str)) else manifest - return Project(manifest, config_override=config_override) - def __ape_extra_attributes__(self) -> Iterator[ExtraModelAttributes]: extras = ( ExtraModelAttributes( diff --git a/src/ape/utils/__init__.py b/src/ape/utils/__init__.py index 6ac504d878..108bf083d0 100644 --- a/src/ape/utils/__init__.py +++ b/src/ape/utils/__init__.py @@ -52,6 +52,7 @@ expand_environment_variables, get_all_files_in_directory, get_full_extension, + get_package_path, get_relative_path, in_tempdir, path_match, @@ -98,6 +99,7 @@ "get_all_files_in_directory", "get_current_timestamp_ms", "get_full_extension", + "get_package_path", "pragma_str_to_specifier_set", "in_tempdir", "injected_before_use", diff --git a/src/ape/utils/os.py b/src/ape/utils/os.py index 34c3123f8a..75b24508d7 100644 --- a/src/ape/utils/os.py +++ b/src/ape/utils/os.py @@ -4,6 +4,7 @@ from collections.abc import Callable, Iterator from contextlib import contextmanager from fnmatch import fnmatch +from importlib.metadata import PackageNotFoundError, distribution from pathlib import Path from re import Pattern from tempfile import TemporaryDirectory, gettempdir @@ -302,3 +303,25 @@ def clean_path(path: Path) -> str: return f"$HOME{os.path.sep}{path.relative_to(home)}" return f"{path}" + + +def get_package_path(package_name: str) -> Path: + """ + Get the path to a package from site-packages. + + Args: + package_name (str): The name of the package. + + Returns: + Path + """ + try: + dist = distribution(package_name) + except PackageNotFoundError as err: + raise ValueError(f"Package '{package_name}' not found in site-packages.") from err + + package_path = Path(str(dist.locate_file(""))) / package_name + if not package_path.exists(): + raise ValueError(f"Package '{package_name}' not found in site-packages.") + + return package_path diff --git a/src/ape_pm/__init__.py b/src/ape_pm/__init__.py index 0a63d19d7d..08823d9635 100644 --- a/src/ape_pm/__init__.py +++ b/src/ape_pm/__init__.py @@ -1,7 +1,7 @@ from ape import plugins from .compiler import InterfaceCompiler -from .dependency import GithubDependency, LocalDependency, NpmDependency +from .dependency import GithubDependency, LocalDependency, NpmDependency, PythonDependency from .projects import BrownieProject, FoundryProject @@ -15,6 +15,7 @@ def dependencies(): yield "github", GithubDependency yield "local", LocalDependency yield "npm", NpmDependency + yield "python", PythonDependency @plugins.register(plugins.ProjectPlugin) @@ -30,4 +31,5 @@ def projects(): "InterfaceCompiler", "LocalDependency", "NpmDependency", + "PythonDependency", ] diff --git a/src/ape_pm/dependency.py b/src/ape_pm/dependency.py index d7d0bfe94c..d7bc88fbb1 100644 --- a/src/ape_pm/dependency.py +++ b/src/ape_pm/dependency.py @@ -3,6 +3,7 @@ import shutil from collections.abc import Iterable from functools import cached_property +from importlib import metadata from pathlib import Path from typing import Optional, Union @@ -12,10 +13,24 @@ from ape.exceptions import ProjectError from ape.logging import logger from ape.managers.project import _version_to_options -from ape.utils import clean_path, in_tempdir +from ape.utils import ManagerAccessMixin, clean_path, get_package_path, in_tempdir from ape.utils._github import _GithubClient, github_client +def _fetch_local(src: Path, destination: Path, config_override: Optional[dict] = None): + if src.is_dir(): + project = ManagerAccessMixin.Project(src, config_override=config_override) + project.unpack(destination) + elif src.is_file() and src.suffix == ".json": + # Using a manifest directly as a dependency. + if not destination.suffix: + destination = destination / src.name + + destination.unlink(missing_ok=True) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(src.read_text(), encoding="utf8") + + class LocalDependency(DependencyAPI): """ A dependency located on the local machine. @@ -75,17 +90,7 @@ def fetch(self, destination: Path): if destination.is_dir(): destination = destination / self.name - if self.local.is_dir(): - project = self.Project(self.local, config_override=self.config_override) - project.unpack(destination) - elif self.local.is_file() and self.local.suffix == ".json": - # Using a manifest directly as a dependency. - if not destination.suffix: - destination = destination / self.local.name - - destination.unlink(missing_ok=True) - destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_text(self.local.read_text(), encoding="utf8") + _fetch_local(self.local, destination, config_override=self.config_override) class GithubDependency(DependencyAPI): @@ -381,3 +386,61 @@ def _get_version_from_package_json( return None return data.get("version") + + +class PythonDependency(DependencyAPI): + """ + A dependency installed from Python, such as files published to PyPI. + """ + + python: str + """ + The Python site-package name. + """ + + version: Optional[str] = None + """ + Optionally specify the version expected to be installed. + """ + + @model_validator(mode="before") + @classmethod + def validate_model(cls, values): + if "name" not in values and "python" in values: + values["name"] = values["python"] + + return values + + @cached_property + def path(self) -> Path: + try: + return get_package_path(self.python) + except ValueError as err: + raise ProjectError(str(err)) from err + + @property + def package_id(self) -> str: + return self.python + + @property + def version_id(self) -> str: + try: + vers = f"{metadata.version(self.python)}" + except metadata.PackageNotFoundError as err: + raise ProjectError(f"Dependency '{self.python}' not found installed.") from err + + if spec_vers := self.version: + if spec_vers != vers: + raise ProjectError( + "Dependency installed with mismatched version. " + f"Expecting '{self.version}' but has '{vers}'" + ) + + return vers + + @property + def uri(self) -> str: + return self.path.as_uri() + + def fetch(self, destination: Path): + _fetch_local(self.path, destination, config_override=self.config_override) diff --git a/tests/functional/test_dependencies.py b/tests/functional/test_dependencies.py index c78604efe5..800a550f32 100644 --- a/tests/functional/test_dependencies.py +++ b/tests/functional/test_dependencies.py @@ -9,7 +9,7 @@ import ape from ape.managers.project import Dependency, LocalProject, PackagesCache, Project, ProjectManager from ape.utils import create_tempdir -from ape_pm.dependency import GithubDependency, LocalDependency, NpmDependency +from ape_pm.dependency import GithubDependency, LocalDependency, NpmDependency, PythonDependency from tests.conftest import skip_if_plugin_installed @@ -579,6 +579,28 @@ def test_fetch_ref(self, mock_client): ) +class TestPythonDependency: + @pytest.fixture(scope="class") + def web3_dependency(self): + return PythonDependency.model_validate({"python": "web3"}) + + def test_name(self, web3_dependency): + assert web3_dependency.name == "web3" + + def test_version_id(self, web3_dependency): + actual = web3_dependency.version_id + assert isinstance(actual, str) + assert len(actual) > 0 + assert actual[0].isnumeric() + assert "." in actual # sep from minor / major / patch + + def test_fetch(self, web3_dependency): + with create_tempdir() as temp_dir: + web3_dependency.fetch(temp_dir) + files = [x for x in temp_dir.iterdir()] + assert len(files) > 0 + + class TestDependency: @pytest.fixture def api(self): diff --git a/tests/functional/test_project.py b/tests/functional/test_project.py index 758f6aa0e6..bc531aebe2 100644 --- a/tests/functional/test_project.py +++ b/tests/functional/test_project.py @@ -601,6 +601,12 @@ def test_from_manifest_load_contracts(self, contract_type): # Also, show it got set on the manifest. assert project.manifest.contract_types == {contract_type.name: contract_type} + def test_from_python_library(self): + # web3py as an ape-project dependency. + web3 = Project.from_python_library("web3") + assert "site-packages" in str(web3.path) + assert web3.path.name == "web3" + class TestBrownieProject: """