Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(bug): prefix config environment vars to avoid conflicts #2479

Merged
merged 11 commits into from
Jan 28, 2025
51 changes: 50 additions & 1 deletion docs/userguides/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,58 @@ plugin:

This helps keep your secrets out of Ape!

Similarly, any config key-name can also be set with the same named environment variable (with a prefix).

If a configuration is left unset (i.e., not included in the `ape-config.(yaml|json|toml)` file, Ape will inspect the environment variables as a fallback, following the pattern `APE_<PLUGIN?>_SETTING`, where different plugins define different prefixes.

For example, the following config:

```yaml
contracts_folder: src/qwe
test:
number_of_accounts: 3
show_internal: True
compile:
exclude:
- "one"
- "two"
- "three"
include_dependencies: true
```

could be entirely defined with environment variables as follows:

```shell
APE_CONTRACTS_FOLDER=src/contracts
APE_TEST_NUMBER_OF_ACCOUNTS=3
APE_TEST_SHOW_INTERNAL=true
APE_COMPILE_EXCLUDE='["one", "two", "three"]'
APE_COMPILE_INCLUDE_DEPENDENCIES=true
```

Notice the `ape-compile` and `ape-test` plugin include their plugin name `APE_COMPILE` and `APE_TEST` respectively where `contracts_folder` only has the prefix `APE_` since it is not part of a plugin.

Here is the complete list of supported prefixes that come with Ape out-of-the-box:

| Module/Plugin | Prefix |
| ------------- | ------------ |
| ape | APE |
| ape_cache | APE_CACHE |
| ape_compile | APE_COMPILE |
| ape_console | APE_CONSOLE |
| ape_ethereum | APE_ETHEREUM |
| ape_networks | APE_NETWORKS |
| ape_node | APE_NODE |
| ape_test | APE_TEST |

Each plugin outside the core package may define its own prefix, but the standard is `APE_PLUGINNAME_`.

Using environment variables assists in keeping secrets out of your config files.
However, the primary config should be file-driven and environment variables should only be used when necessary.

## Base Path

Change the base path if it is different than your project root.
Change the base path if it is different from your project root.
For example, imagine a project structure like:

```
Expand Down
6 changes: 3 additions & 3 deletions src/ape/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class PluginConfig(BaseSettings):
a config API must register a subclass of this class.
"""

model_config = SettingsConfigDict(extra="allow")
model_config = SettingsConfigDict(extra="allow", env_prefix="APE_")

@classmethod
def from_overrides(
Expand Down Expand Up @@ -285,7 +285,7 @@ class ApeConfig(ExtraAttributesMixin, BaseSettings, ManagerAccessMixin):

def __init__(self, *args, **kwargs):
project_path = kwargs.get("project")
super(BaseSettings, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
# NOTE: Cannot reference `self` at all until after super init.
self._project_path = project_path

Expand Down Expand Up @@ -350,7 +350,7 @@ def __init__(self, *args, **kwargs):
"""

# NOTE: Plugin configs are technically "extras".
model_config = SettingsConfigDict(extra="allow")
model_config = SettingsConfigDict(extra="allow", env_prefix="APE_")

@model_validator(mode="before")
@classmethod
Expand Down
3 changes: 3 additions & 0 deletions src/ape_cache/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from pydantic_settings import SettingsConfigDict

from ape.api.config import PluginConfig


class CacheConfig(PluginConfig):
size: int = 1024**3 # 1gb
model_config = SettingsConfigDict(extra="allow", env_prefix="APE_CACHE_")
3 changes: 3 additions & 0 deletions src/ape_compile/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Union

from pydantic import field_serializer, field_validator
from pydantic_settings import SettingsConfigDict

from ape.api.config import ConfigEnum, PluginConfig
from ape.utils.misc import SOURCE_EXCLUDE_PATTERNS
Expand Down Expand Up @@ -53,6 +54,8 @@ class Config(PluginConfig):
Extra selections to output. Outputs to ``.build/{key.lower()}``.
"""

model_config = SettingsConfigDict(extra="allow", env_prefix="APE_COMPILE_")

@field_validator("exclude", mode="before")
@classmethod
def validate_exclude(cls, value):
Expand Down
4 changes: 4 additions & 0 deletions src/ape_console/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from pydantic_settings import SettingsConfigDict

from ape.api.config import PluginConfig


class ConsoleConfig(PluginConfig):
plugins: list[str] = []
"""Additional IPython plugins to include in your session."""

model_config = SettingsConfigDict(extra="allow", env_prefix="APE_CONSOLE_")
4 changes: 3 additions & 1 deletion src/ape_ethereum/ecosystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ class NetworkConfig(PluginConfig):
request_headers: dict = {}
"""Optionally config extra request headers whenever using this network."""

model_config = SettingsConfigDict(extra="allow", env_prefix="APE_ETHEREUM_")

@field_validator("gas_limit", mode="before")
@classmethod
def validate_gas_limit(cls, value):
Expand Down Expand Up @@ -233,7 +235,7 @@ class BaseEthereumConfig(PluginConfig):
# NOTE: This gets appended to Ape's root User-Agent string.
request_headers: dict = {}

model_config = SettingsConfigDict(extra="allow")
model_config = SettingsConfigDict(extra="allow", env_prefix="APE_ETHEREUM_")

@model_validator(mode="before")
@classmethod
Expand Down
5 changes: 5 additions & 0 deletions src/ape_networks/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from typing import Optional

from pydantic_settings import SettingsConfigDict

from ape.api.config import PluginConfig


Expand All @@ -26,6 +28,8 @@ class CustomNetwork(PluginConfig):
request_header: dict = {}
"""The HTTP request header."""

model_config = SettingsConfigDict(extra="allow", env_prefix="APE_NETWORKS_")

@property
def is_fork(self) -> bool:
"""
Expand All @@ -36,3 +40,4 @@ def is_fork(self) -> bool:

class NetworksConfig(PluginConfig):
custom: list[CustomNetwork] = []
model_config = SettingsConfigDict(extra="allow", env_prefix="APE_NETWORKS_")
4 changes: 2 additions & 2 deletions src/ape_node/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ class EthereumNetworkConfig(PluginConfig):
# Make sure to run via `geth --dev` (or similar)
local: dict = {**DEFAULT_SETTINGS.copy(), "chain_id": DEFAULT_TEST_CHAIN_ID}

model_config = SettingsConfigDict(extra="allow")
model_config = SettingsConfigDict(extra="allow", env_prefix="APE_NODE_")

@field_validator("local", mode="before")
@classmethod
Expand Down Expand Up @@ -357,7 +357,7 @@ class EthereumNodeConfig(PluginConfig):
Optionally specify request headers to use whenever using this provider.
"""

model_config = SettingsConfigDict(extra="allow")
model_config = SettingsConfigDict(extra="allow", env_prefix="APE_NODE_")

@field_validator("call_trace_approach", mode="before")
@classmethod
Expand Down
13 changes: 13 additions & 0 deletions src/ape_test/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import TYPE_CHECKING, NewType, Optional, Union

from pydantic import NonNegativeInt, field_validator
from pydantic_settings import SettingsConfigDict

from ape.api.config import PluginConfig
from ape.utils.basemodel import ManagerAccessMixin
Expand All @@ -19,11 +20,13 @@
class EthTesterProviderConfig(PluginConfig):
chain_id: int = DEFAULT_TEST_CHAIN_ID
auto_mine: bool = True
model_config = SettingsConfigDict(extra="allow", env_prefix="APE_TEST_")


class GasExclusion(PluginConfig):
contract_name: str = "*" # If only given method, searches across all contracts.
method_name: Optional[str] = None # By default, match all methods in a contract
model_config = SettingsConfigDict(extra="allow", env_prefix="APE_TEST_")


CoverageExclusion = NewType("CoverageExclusion", GasExclusion)
Expand All @@ -48,6 +51,8 @@ class GasConfig(PluginConfig):
Report-types to use. Currently, only supports `terminal`.
"""

model_config = SettingsConfigDict(extra="allow", env_prefix="APE_TEST_")

@field_validator("reports", mode="before")
@classmethod
def validate_reports(cls, values):
Expand Down Expand Up @@ -89,6 +94,8 @@ class CoverageReportsConfig(PluginConfig):
Set to ``True`` to generate HTML coverage reports.
"""

model_config = SettingsConfigDict(extra="allow", env_prefix="APE_TEST_")

@property
def has_any(self) -> bool:
return any(x not in ({}, None, False) for x in (self.html, self.terminal, self.xml))
Expand Down Expand Up @@ -119,6 +126,8 @@ class CoverageConfig(PluginConfig):
use ``prefix_*`` to skip all items with a certain prefix.
"""

model_config = SettingsConfigDict(extra="allow", env_prefix="APE_TEST_")


class IsolationConfig(PluginConfig):
enable_session: bool = True
Expand Down Expand Up @@ -146,6 +155,8 @@ class IsolationConfig(PluginConfig):
Set to ``False`` to disable function isolation.
"""

model_config = SettingsConfigDict(extra="allow", env_prefix="APE_TEST_")

def get_isolation(self, scope: "Scope") -> bool:
return getattr(self, f"enable_{scope.name.lower()}")

Expand Down Expand Up @@ -209,6 +220,8 @@ class ApeTestConfig(PluginConfig):
``False`` to disable all and ``True`` (default) to disable all.
"""

model_config = SettingsConfigDict(extra="allow", env_prefix="APE_TEST_")

@field_validator("balance", mode="before")
@classmethod
def validate_balance(cls, value):
Expand Down
77 changes: 73 additions & 4 deletions tests/functional/test_config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import re
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Union
from typing import TYPE_CHECKING, Any, Callable, Optional, Union

import pytest
from pydantic import ValidationError
Expand All @@ -11,8 +11,12 @@
from ape.exceptions import ConfigError
from ape.managers.config import CONFIG_FILE_NAME, merge_configs
from ape.utils.os import create_tempdir
from ape_ethereum.ecosystem import EthereumConfig, NetworkConfig
from ape_networks import CustomNetwork
from ape_cache.config import CacheConfig
from ape_compile.config import Config as CompileConfig
from ape_ethereum.ecosystem import EthereumConfig, ForkedNetworkConfig, NetworkConfig
from ape_networks.config import CustomNetwork
from ape_node.provider import EthereumNetworkConfig, EthereumNodeConfig
from ape_test.config import CoverageReportsConfig, GasConfig, GasExclusion
from tests.functional.conftest import PROJECT_WITH_LONG_CONTRACTS_FOLDER

if TYPE_CHECKING:
Expand Down Expand Up @@ -129,6 +133,71 @@ def test_model_validate_path_contracts_folder():
assert cfg.contracts_folder == str(path)


def test_model_validate_handles_environment_variables():
def run_test(cls: Callable, attr: str, name: str, value: str, expected: Any = None):
expected = expected if expected is not None else value
before: str | None = os.environ.get(name)
os.environ[name] = value
try:
instance = cls()
assert getattr(instance, attr) == expected
finally:
if before is not None:
os.environ[name] = before

# Test different config classes.
run_test(ApeConfig, "contracts_folder", "APE_CONTRACTS_FOLDER", "3465220869b2")
run_test(CacheConfig, "size", "APE_CACHE_SIZE", "8627", 8627)
run_test(
CompileConfig, "include_dependencies", "APE_COMPILE_INCLUDE_DEPENDENCIES", "true", True
)
run_test(
ForkedNetworkConfig, "upstream_provider", "APE_ETHEREUM_UPSTREAM_PROVIDER", "411236f13659"
)
run_test(
NetworkConfig, "required_confirmations", "APE_ETHEREUM_REQUIRED_CONFIRMATIONS", "6498", 6498
)
run_test(EthereumNetworkConfig, "mainnet", "APE_NODE_MAINNET", '{"a":"b"}', {"a": "b"})
run_test(EthereumNodeConfig, "executable", "APE_NODE_EXECUTABLE", "40613177e494")
run_test(CoverageReportsConfig, "terminal", "APE_TEST_TERMINAL", "false", False)
run_test(GasConfig, "reports", "APE_TEST_REPORTS", '["terminal"]', ["terminal"])
run_test(GasExclusion, "method_name", "APE_TEST_METHOD_NAME", "32aa54e3c5d2")

# Assert that union types are handled.
run_test(NetworkConfig, "gas_limit", "APE_ETHEREUM_GAS_LIMIT", "0", 0)
run_test(NetworkConfig, "gas_limit", "APE_ETHEREUM_GAS_LIMIT", "0x100", 0x100)
run_test(NetworkConfig, "gas_limit", "APE_ETHEREUM_GAS_LIMIT", "auto")
run_test(NetworkConfig, "gas_limit", "APE_ETHEREUM_GAS_LIMIT", "max")
with pytest.raises(ValidationError, match=r"Value error, Invalid gas limit"):
run_test(NetworkConfig, "gas_limit", "APE_ETHEREUM_GAS_LIMIT", "something")

# Assert that various bool variants are parsed correctly.
for bool_val in ("0", "False", "fALSE", "FALSE"):
run_test(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", bool_val, False)

for bool_val in ("1", "True", "tRUE", "TRUE"):
run_test(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", bool_val, True)

# We expect a failure when there's a type mismatch.
with pytest.raises(
ValidationError,
match=r"Input should be a valid boolean, unable to interpret input",
):
run_test(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "not a boolean", False)

with pytest.raises(
ValidationError,
match=r"Input should be a valid integer, unable to parse string as an integer",
):
run_test(
NetworkConfig,
"required_confirmations",
"APE_ETHEREUM_REQUIRED_CONFIRMATIONS",
"not a number",
42,
)


@pytest.mark.parametrize(
"file", ("ape-config.yml", "ape-config.yaml", "ape-config.json", "pyproject.toml")
)
Expand All @@ -152,7 +221,7 @@ def test_validate_file(file):
assert "Excl*.json" in actual.compile.exclude


def test_validate_file_expands_env_vars():
def test_validate_file_expands_environment_variables():
secret = "mycontractssecretfolder"
env_var_name = "APE_TEST_CONFIG_SECRET_CONTRACTS_FOLDER"
os.environ[env_var_name] = secret
Expand Down
2 changes: 1 addition & 1 deletion tests/functional/test_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ def init_profile(source_cov, src):

try:
# Hack in our mock compiler.
_ = compilers.registered_compilers # Ensure cache is exists.
_ = compilers.registered_compilers # Ensure cache exists.
compilers.__dict__["registered_compilers"][mock_compiler.ext] = mock_compiler

# Ensure our coverage tracker is using our new tmp project w/ the new src
Expand Down
Loading