Skip to content

Commit

Permalink
fix(bug): prefix config environment vars to avoid conflicts (#2479)
Browse files Browse the repository at this point in the history
  • Loading branch information
ydm authored Jan 28, 2025
1 parent 9b4184d commit 978c296
Show file tree
Hide file tree
Showing 11 changed files with 162 additions and 12 deletions.
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
79 changes: 75 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,73 @@ 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
else:
os.environ.pop(name, None)

# 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 +223,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

0 comments on commit 978c296

Please sign in to comment.