diff --git a/docs/userguides/compile.md b/docs/userguides/compile.md index 69e772cd42..25a5b3ff3a 100644 --- a/docs/userguides/compile.md +++ b/docs/userguides/compile.md @@ -81,3 +81,32 @@ Alternatively, configure it to always happen: compile: use_dependencies: true ``` + +## Settings + +Generally, configure compiler plugins using your `ape-config.yaml` file. +For example, when using the `vyper` plugin, you can configure settings under the `vyper` key: + +```yaml +vyper: + version: 0.3.10 +``` + +You can also configure adhoc settings in Python code: + +```python +from pathlib import Path +from ape import compilers + +settings = {"vyper": {"version": "0.3.7"}, "solidity": {"version": "0.8.0"}} +compilers.compile( + ["path/to/contract.vy", "path/to/contract.sol"], settings=settings +) + +# Or, more explicitly: +vyper = compilers.get_compiler("vyper", settings=settings["vyper"]) +vyper.compile([Path("path/to/contract.vy")]) + +solidity = compilers.get_compiler("solidity", settings=settings["solidity"]) +vyper.compile([Path("path/to/contract.sol")]) +``` diff --git a/src/ape/api/compiler.py b/src/ape/api/compiler.py index 11515f83c5..3bd34b317a 100644 --- a/src/ape/api/compiler.py +++ b/src/ape/api/compiler.py @@ -8,6 +8,7 @@ from evm_trace.geth import create_call_node_data from semantic_version import Version # type: ignore +from ape.api.config import PluginConfig from ape.exceptions import APINotImplementedError, ContractLogicError from ape.types.coverage import ContractSourceCoverage from ape.types.trace import SourceTraceback, TraceFrame @@ -25,11 +26,32 @@ class CompilerAPI(BaseInterfaceModel): this API. """ + compiler_settings: Dict = {} + """ + Adhoc compiler settings. + """ + @property @abstractmethod def name(self) -> str: ... + @property + def config(self) -> PluginConfig: + """ + The provider's configuration. + """ + return self.config_manager.get_config(self.name) + + @property + def settings(self) -> PluginConfig: + """ + The combination of settings from ``ape-config.yaml`` and ``.compiler_settings``. + """ + CustomConfig = self.config.__class__ + data = {**self.config.dict(), **self.compiler_settings} + return CustomConfig.parse_obj(data) + @abstractmethod def get_versions(self, all_paths: List[Path]) -> Set[str]: """ diff --git a/src/ape/managers/compilers.py b/src/ape/managers/compilers.py index 5be5adcb28..f9c2f50ba5 100644 --- a/src/ape/managers/compilers.py +++ b/src/ape/managers/compilers.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional, Sequence, Set, Union from ethpm_types import ContractType from ethpm_types.source import Content @@ -72,14 +72,22 @@ def registered_compilers(self) -> Dict[str, CompilerAPI]: self._registered_compilers_cache[cache_key] = registered_compilers return registered_compilers - def get_compiler(self, name: str) -> Optional[CompilerAPI]: + def get_compiler(self, name: str, settings: Optional[Dict] = None) -> Optional[CompilerAPI]: for compiler in self.registered_compilers.values(): - if compiler.name == name: - return compiler + if compiler.name != name: + continue + + if settings is not None and settings != compiler.compiler_settings: + # Use a new instance to support multiple compilers of same type. + return compiler.copy(update={"compiler_settings": settings}) + + return compiler return None - def compile(self, contract_filepaths: List[Path]) -> Dict[str, ContractType]: + def compile( + self, contract_filepaths: Sequence[Union[Path, str]], settings: Optional[Dict] = None + ) -> Dict[str, ContractType]: """ Invoke :meth:`ape.ape.compiler.CompilerAPI.compile` for each of the given files. For example, use the `ape-solidity plugin `__ @@ -90,13 +98,17 @@ def compile(self, contract_filepaths: List[Path]) -> Dict[str, ContractType]: extension as well as when there is a contract-type collision across compilers. Args: - contract_filepaths (List[pathlib.Path]): The list of files to compile, - as ``pathlib.Path`` objects. + contract_filepaths (Sequence[Union[pathlib.Path], str]): The list of files to compile, + as ``pathlib.Path`` objects. You can also pass a list of `str` that will + automatically get turned to ``pathlib.Path`` objects. + settings (Optional[Dict]): Adhoc compiler settings. Defaults to None. + Ensure the compiler name key is present in the dict for it to work. Returns: Dict[str, ``ContractType``]: A mapping of contract names to their type. """ - extensions = self._get_contract_extensions(contract_filepaths) + contract_file_paths = [Path(p) if isinstance(p, str) else p for p in contract_filepaths] + extensions = self._get_contract_extensions(contract_file_paths) contracts_folder = self.config_manager.contracts_folder contract_types_dict: Dict[str, ContractType] = {} built_paths = [p for p in self.project_manager.local_project._cache_folder.glob("*.json")] @@ -114,7 +126,7 @@ def compile(self, contract_filepaths: List[Path]) -> Dict[str, ContractType]: # Filter out in-source cache files from dependencies. paths_to_compile = [ path - for path in contract_filepaths + for path in contract_file_paths if path.is_file() and path not in paths_to_ignore and path not in built_paths @@ -126,9 +138,14 @@ def compile(self, contract_filepaths: List[Path]) -> Dict[str, ContractType]: for source_id in source_ids: logger.info(f"Compiling '{source_id}'.") - compiled_contracts = self.registered_compilers[extension].compile( - paths_to_compile, base_path=contracts_folder - ) + name = self.registered_compilers[extension].name + compiler = self.get_compiler(name, settings=settings) + if compiler is None: + # For mypy - should not be possible. + raise ValueError("Compiler should not be None") + + compiled_contracts = compiler.compile(paths_to_compile, base_path=contracts_folder) + for contract_type in compiled_contracts: contract_name = contract_type.name if not contract_name: @@ -180,14 +197,14 @@ def compile(self, contract_filepaths: List[Path]) -> Dict[str, ContractType]: return contract_types_dict def get_imports( - self, contract_filepaths: List[Path], base_path: Optional[Path] = None + self, contract_filepaths: Sequence[Path], base_path: Optional[Path] = None ) -> Dict[str, List[str]]: """ Combine import dicts from all compilers, where the key is a contract's source_id and the value is a list of import source_ids. Args: - contract_filepaths (List[pathlib.Path]): A list of source file paths to compile. + contract_filepaths (Sequence[pathlib.Path]): A list of source file paths to compile. base_path (Optional[pathlib.Path]): Optionally provide the base path, such as the project ``contracts/`` directory. Defaults to ``None``. When using in a project via ``ape compile``, gets set to the project's ``contracts/`` directory. diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index 8f54482b2e..f1babaf700 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -661,9 +661,11 @@ def method_abi_with_struct_input(): @pytest.fixture def mock_compiler(mocker): mock = mocker.MagicMock() + mock.name = "mock" mock.ext = ".__mock__" - def mock_compile(paths, *args, **kwargs): + def mock_compile(paths, base_path=None): + mock.tracked_settings.append(mock.compiler_settings) result = [] for path in paths: if path.suffix == mock.ext: diff --git a/tests/functional/test_compilers.py b/tests/functional/test_compilers.py index 4c9a7c814e..f6330ed772 100644 --- a/tests/functional/test_compilers.py +++ b/tests/functional/test_compilers.py @@ -45,6 +45,22 @@ def test_get_compiler(compilers): assert compilers.get_compiler("foobar") is None +def test_get_compiler_with_settings(compilers, mock_compiler, project_with_contract): + existing_compilers = compilers._registered_compilers_cache[project_with_contract.path] + all_compilers = {**existing_compilers, mock_compiler.ext: mock_compiler} + + try: + compilers._registered_compilers_cache[project_with_contract.path] = all_compilers + compiler_0 = compilers.get_compiler("mock", settings={"bar": "foo"}) + compiler_1 = compiler_0.get_compiler("mock", settings={"foo": "bar"}) + + finally: + compilers._registered_compilers_cache[project_with_contract.path] = existing_compilers + + assert compiler_0.compiler_settings != compiler_1.compiler_settings + assert id(compiler_0) != id(compiler_1) + + def test_getattr(compilers): compiler = compilers.ethpm assert compiler.name == "ethpm" @@ -89,3 +105,28 @@ def test_contract_type_collision(compilers, project_with_contract, mock_compiler finally: compilers._registered_compilers_cache[project_with_contract.path] = existing_compilers + + +def test_compile_with_settings(mock_compiler, compilers, project_with_contract): + existing_compilers = compilers._registered_compilers_cache[project_with_contract.path] + all_compilers = {**existing_compilers, mock_compiler.ext: mock_compiler} + new_contract = project_with_contract.path / f"AMockContract{mock_compiler.ext}" + new_contract.write_text("foobar") + settings = {"mock": {"foo": "bar"}} + + try: + compilers._registered_compilers_cache[project_with_contract.path] = all_compilers + compilers.compile([new_contract], settings=settings) + + finally: + compilers._registered_compilers_cache[project_with_contract.path] = existing_compilers + + actual = mock_compiler.method_calls[0][2]["update"]["compiler_settings"]["mock"] + assert actual == settings["mock"] + + +def test_compile_str_path(compilers, project_with_contract): + path = next(iter(project_with_contract.source_paths)) + actual = compilers.compile([str(path)]) + contract_name = path.stem + assert actual[contract_name].name == contract_name