Skip to content

Commit

Permalink
feat: allow configuring base_path: in projects (#2295)
Browse files Browse the repository at this point in the history
  • Loading branch information
antazoey committed Sep 19, 2024
1 parent a561cde commit ac298db
Show file tree
Hide file tree
Showing 6 changed files with 85 additions and 39 deletions.
21 changes: 21 additions & 0 deletions docs/userguides/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,27 @@ plugin:
This helps keep your secrets out of Ape!
## Base Path
Change the base path if it is different than your project root.
For example, imagine a project structure like:
```
project
└── src/
└── contracts/
└── MyContract.sol
```

In this case, you want to configure Ape like:

```yaml
base_path: src
```
This way, `MyContract.vy`'s source ID will be `"contracts/Factory.vy"` and not `"src/contracts/Factory.vy"`.
Some dependencies, such as python-based ones like `snekmate`, use this structure.

## Contracts Folder

Specify a different path to your `contracts/` directory.
Expand Down
6 changes: 6 additions & 0 deletions src/ape/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,12 @@ def __init__(self, *args, **kwargs):
The name of the project.
"""

base_path: Optional[str] = None
"""
Use this when the project's base-path is not the
root of the project.
"""

request_headers: dict = {}
"""
Extra request headers for all HTTP requests.
Expand Down
1 change: 0 additions & 1 deletion src/ape/managers/networks.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,6 @@ def default_ecosystem(self) -> EcosystemAPI:
only a single ecosystem installed, such as Ethereum, then get
that ecosystem.
"""

return self.ecosystems[self.default_ecosystem_name]

def set_default_ecosystem(self, ecosystem_name: str):
Expand Down
20 changes: 12 additions & 8 deletions src/ape/managers/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -2118,16 +2118,20 @@ def __init__(
config_override: Optional[dict] = None,
) -> None:
self._session_source_change_check: set[str] = set()
self.path = Path(path).resolve()
# A local project uses a special manifest.
self.manifest_path = manifest_path or self.path / ".build" / "__local__.json"
manifest = self.load_manifest()

# NOTE: Set this before super() because needed for self.config read.
self._config_override = config_override or {}

self._base_path = Path(path).resolve()

# A local project uses a special manifest.
self.manifest_path = manifest_path or self._base_path / ".build" / "__local__.json"
manifest = self.load_manifest()

super().__init__(manifest, config_override=self._config_override)

self.path = self._base_path / (self.config.base_path or "")

# NOTE: Avoid pointlessly adding info to the __local__ manifest.
# This is mainly for dependencies.
if self.manifest_path.stem != "__local__" and not manifest.sources:
Expand Down Expand Up @@ -2276,7 +2280,7 @@ def project_api(self) -> ProjectAPI:
]
plugins = [t for t in project_classes if not issubclass(t, ApeProject)]
for api in plugins:
if instance := api.attempt_validate(path=self.path):
if instance := api.attempt_validate(path=self._base_path):
return instance

# If no other APIs worked but we have a default Ape project, use that!
Expand All @@ -2286,10 +2290,10 @@ def project_api(self) -> ProjectAPI:

# For some reason we were just not able to create a project here.
# I am not sure this is even possible.
raise ProjectError(f"'{self.path.name}' is not recognized as a project.")
raise ProjectError(f"'{self._base_path.name}' is not recognized as a project.")

def _get_ape_project_api(self) -> Optional[ApeProject]:
if instance := ApeProject.attempt_validate(path=self.path):
if instance := ApeProject.attempt_validate(path=self._base_path):
return cast(ApeProject, instance)

return None
Expand All @@ -2301,7 +2305,7 @@ def name(self) -> str:
elif name := self.manifest.name:
return name

return self.path.name.replace("_", "-").lower()
return self._base_path.name.replace("_", "-").lower()

@cached_property
def config(self) -> ApeConfig:
Expand Down
50 changes: 46 additions & 4 deletions tests/functional/test_project.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import os
import shutil
from pathlib import Path

Expand All @@ -12,7 +13,7 @@
from ape import Project
from ape.api.projects import ApeProject
from ape.contracts import ContractContainer
from ape.exceptions import ProjectError
from ape.exceptions import ConfigError, ProjectError
from ape.logging import LogLevel
from ape.utils import create_tempdir
from ape_pm import BrownieProject, FoundryProject
Expand Down Expand Up @@ -93,6 +94,31 @@ def test_path(project):
assert project.path is not None


def test_path_configured(project):
"""
Simulating package structures like snekmate.
"""
madeup_name = "snakemate"
with create_tempdir(name=madeup_name) as temp_dir:
subdir = temp_dir / "src"
contracts_folder = subdir / madeup_name
contracts_folder.mkdir(parents=True)
contract = contracts_folder / "snake.json"
abi = [{"name": "foo", "type": "fallback", "stateMutability": "nonpayable"}]
contract.write_text(json.dumps(abi), encoding="utf8")

snekmate = Project(
temp_dir, config_override={"base_path": "src", "contracts_folder": madeup_name}
)
assert snekmate.name == madeup_name
assert snekmate.path == subdir
assert snekmate.contracts_folder == contracts_folder

actual = snekmate.load_contracts()
assert "snake" in actual
assert actual["snake"].source_id == f"{madeup_name}/snake.json"


def test_name(project):
assert project.name == project.path.name

Expand Down Expand Up @@ -641,6 +667,21 @@ def test_init(self, with_dependencies_project_path):
# Manifest should have been created by default.
assert not project.manifest_path.is_file()

def test_init_invalid_config(self):
here = os.curdir
with create_tempdir() as temp_dir:
cfgfile = temp_dir / "ape-config.yaml"
# Name is invalid!
cfgfile.write_text("name:\n {asdf}")

os.chdir(temp_dir)
expected = r"[.\n]*Input should be a valid string\n-->1: name:\n 2: {asdf}[.\n]*"
try:
with pytest.raises(ConfigError, match=expected):
_ = Project(temp_dir)
finally:
os.chdir(here)

def test_config_override(self, with_dependencies_project_path):
contracts_folder = with_dependencies_project_path / "my_contracts"
config = {"contracts_folder": contracts_folder.name}
Expand Down Expand Up @@ -760,11 +801,12 @@ def gitmodules(self):
)

def test_extract_config(self, foundry_toml, gitmodules, mock_github):
with ape.Project.create_temporary_project() as temp_project:
cfg_file = temp_project.path / "foundry.toml"
with create_tempdir() as temp_dir:
cfg_file = temp_dir / "foundry.toml"
cfg_file.write_text(foundry_toml, encoding="utf8")
gitmodules_file = temp_project.path / ".gitmodules"
gitmodules_file = temp_dir / ".gitmodules"
gitmodules_file.write_text(gitmodules, encoding="utf8")
temp_project = Project(temp_dir)

api = temp_project.project_api
mock_github.get_repo.return_value = {"default_branch": "main"}
Expand Down
26 changes: 0 additions & 26 deletions tests/integration/cli/test_misc.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import os
import re

import pytest

from ape import Project
from tests.conftest import ApeSubprocessRunner
from tests.integration.cli.utils import run_once


Expand Down Expand Up @@ -33,26 +30,3 @@ def test_help(ape_cli, runner):
rf"test\s*Launches pytest{anything}"
)
assert re.match(expected.strip(), result.output)


@run_once
def test_invalid_config():
# Using subprocess runner so we re-hit the init of the cmd.
runner = ApeSubprocessRunner("ape")
here = os.curdir
with Project.create_temporary_project() as tmp:
cfgfile = tmp.path / "ape-config.yaml"
# Name is invalid!
cfgfile.write_text("name:\n {asdf}")

os.chdir(tmp.path)
result = runner.invoke("--help")
os.chdir(here)

expected = """
Input should be a valid string
-->1: name:
2: {asdf}
""".strip()
assert result.exit_code != 0
assert expected in result.output

0 comments on commit ac298db

Please sign in to comment.