Skip to content

Commit

Permalink
feat: easily create projects from site-packages (#2163)
Browse files Browse the repository at this point in the history
  • Loading branch information
antazoey authored Jul 1, 2024
1 parent 9d40bab commit d59a8e2
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 23 deletions.
11 changes: 11 additions & 0 deletions docs/userguides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/userguides/projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 30 additions & 9 deletions src/ape/managers/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
create_tempdir,
get_all_files_in_directory,
get_full_extension,
get_package_path,
get_relative_path,
in_tempdir,
path_match,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/ape/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions src/ape/utils/os.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 3 additions & 1 deletion src/ape_pm/__init__.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -15,6 +15,7 @@ def dependencies():
yield "github", GithubDependency
yield "local", LocalDependency
yield "npm", NpmDependency
yield "python", PythonDependency


@plugins.register(plugins.ProjectPlugin)
Expand All @@ -30,4 +31,5 @@ def projects():
"InterfaceCompiler",
"LocalDependency",
"NpmDependency",
"PythonDependency",
]
87 changes: 75 additions & 12 deletions src/ape_pm/dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
24 changes: 23 additions & 1 deletion tests/functional/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions tests/functional/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down

0 comments on commit d59a8e2

Please sign in to comment.