diff --git a/docs/userguides/config.md b/docs/userguides/config.md index 447349c824..e61ecb6cdc 100644 --- a/docs/userguides/config.md +++ b/docs/userguides/config.md @@ -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. diff --git a/src/ape/api/config.py b/src/ape/api/config.py index 05f67897f8..3138390b47 100644 --- a/src/ape/api/config.py +++ b/src/ape/api/config.py @@ -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. diff --git a/src/ape/managers/networks.py b/src/ape/managers/networks.py index e247d1528a..a5da8eb96f 100644 --- a/src/ape/managers/networks.py +++ b/src/ape/managers/networks.py @@ -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): diff --git a/src/ape/managers/project.py b/src/ape/managers/project.py index 0db1019141..c63f1192ad 100644 --- a/src/ape/managers/project.py +++ b/src/ape/managers/project.py @@ -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: @@ -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! @@ -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 @@ -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: diff --git a/tests/functional/test_project.py b/tests/functional/test_project.py index 99436d0570..a948765dd7 100644 --- a/tests/functional/test_project.py +++ b/tests/functional/test_project.py @@ -1,4 +1,5 @@ import json +import os import shutil from pathlib import Path @@ -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 @@ -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 @@ -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} @@ -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"} diff --git a/tests/integration/cli/test_misc.py b/tests/integration/cli/test_misc.py index c5451ec675..49933d4b8f 100644 --- a/tests/integration/cli/test_misc.py +++ b/tests/integration/cli/test_misc.py @@ -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 @@ -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