Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Prefix config environment vars to avoid conflicts
Browse files Browse the repository at this point in the history
Yordan Miladinov authored and ydm committed Jan 28, 2025
1 parent a87fba6 commit 668142b
Showing 11 changed files with 169 additions and 9 deletions.
49 changes: 49 additions & 0 deletions docs/userguides/config.md
Original file line number Diff line number Diff line change
@@ -37,6 +37,55 @@ plugin:
This helps keep your secrets out of Ape!
If a configuration is left unset (i.e., not included in the
`ape-config.(yaml|json|toml)` file, Ape will optionally inspect the
environment variables as a fallback, following the pattern
"<PREFIX>_SETTING", where different types of configurations have
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
```

Here is the complete list of supported prefixes:

| Module | 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 |

As this is an experimental feature, changes are likely to be
introduced in the future. Please rely primarily on file-based
configuration and use environment variables only if absolutely
necessary.

## Base Path

Change the base path if it is different than your project root.
6 changes: 3 additions & 3 deletions src/ape/api/config.py
Original file line number Diff line number Diff line change
@@ -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(
@@ -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

@@ -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
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
@@ -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
@@ -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):
10 changes: 10 additions & 0 deletions src/ape_compile/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env python

print(__package__)
print(__name__)

import re

match: re.Match | None = re.search(r'/(ape_\w+)/', __file__)
envprefix: str = f'{match.group(1).upper()}_' if match else 'APE_'
print(envprefix)
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
@@ -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):
@@ -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
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


@@ -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:
"""
@@ -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
@@ -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
@@ -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
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
@@ -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)
@@ -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):
@@ -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))
@@ -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
@@ -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()}")

@@ -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):
77 changes: 74 additions & 3 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 Any, Callable, TYPE_CHECKING, Optional, Type, Union

import pytest
from pydantic import ValidationError
@@ -10,9 +10,14 @@
from ape.api.config import ApeConfig, ConfigEnum, PluginConfig
from ape.exceptions import ConfigError
from ape.managers.config import CONFIG_FILE_NAME, merge_configs
from ape_console.config import ConsoleConfig
from ape_cache.config import CacheConfig
from ape_compile.config import Config as CompileConfig
from ape.utils.os import create_tempdir
from ape_ethereum.ecosystem import EthereumConfig, NetworkConfig
from ape_networks import CustomNetwork
from ape_ethereum.ecosystem import BaseEthereumConfig, EthereumConfig, NetworkConfig, ForkedNetworkConfig
from ape_node.provider import EthereumNetworkConfig, EthereumNodeConfig
from ape_networks.config import CustomNetwork
from ape_test.config import ApeTestConfig, CoverageConfig, CoverageReportsConfig, EthTesterProviderConfig, GasConfig, GasExclusion, IsolationConfig
from tests.functional.conftest import PROJECT_WITH_LONG_CONTRACTS_FOLDER

if TYPE_CHECKING:
@@ -618,3 +623,69 @@ def test_project_level_settings(project):
assert project.config.my_string == "my_string"
assert project.config.my_int == 123
assert project.config.my_bool is True


def test_model_validate_handles_environment_variables():
def f(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 hasattr(instance, attr)
assert getattr(instance, attr) == expected
finally:
if before is not None:
os.environ[name] = before

# Test different config classes.
f(ApeConfig, "contracts_folder", "APE_CONTRACTS_FOLDER", "3465220869b2")
f(ApeConfig, "dependencies", "APE_DEPENDENCIES", '[{"a":1},{"b":2},{"c":3}]', [{"a": 1}, {"b": 2}, {"c": 3}])
f(CacheConfig, "size", "APE_CACHE_SIZE", "8627", 8627)
f(CompileConfig, "include_dependencies", "APE_COMPILE_INCLUDE_DEPENDENCIES", "true", True)
f(ConsoleConfig, "plugins", "APE_CONSOLE_PLUGINS", '["a","b","c"]', ["a", "b", "c"])
f(BaseEthereumConfig, "default_network", "APE_ETHEREUM_DEFAULT_NETWORK", "abe9e8293383")
f(ForkedNetworkConfig, "upstream_provider", "APE_ETHEREUM_UPSTREAM_PROVIDER", "411236f13659")
f(NetworkConfig, "required_confirmations", "APE_ETHEREUM_REQUIRED_CONFIRMATIONS", "6498", 6498)
f(lambda: CustomNetwork(name="", chain_id=0, ecosystem=""),
"base_ecosystem_plugin", "APE_NETWORKS_BASE_ECOSYSTEM_PLUGIN", "ea5010088102")
f(EthereumNetworkConfig, "mainnet", "APE_NODE_MAINNET", '{"a":"b"}', {"a":"b"})
f(EthereumNodeConfig, "executable", "APE_NODE_EXECUTABLE", "40613177e494")
f(ApeTestConfig, "balance", "APE_TEST_BALANCE", "4798", 4798)
f(CoverageConfig, "track", "APE_TEST_TRACK", "true", True)
f(CoverageReportsConfig, "terminal", "APE_TEST_TERMINAL", "false", False)
f(EthTesterProviderConfig, "chain_id", "APE_TEST_CHAIN_ID", "7925", 7925)
f(GasConfig, "reports", "APE_TEST_REPORTS", '["terminal"]', ["terminal"])
f(GasExclusion, "method_name", "APE_TEST_METHOD_NAME", "32aa54e3c5d2")
f(IsolationConfig, "enable_session", "APE_TEST_ENABLE_SESSION", "false", False)

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

# Assert that various bool variants are parsed correctly.
f(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "0", False)
f(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "False", False)
f(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "fALSE", False)
f(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "FALSE", False)
f(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "1", True)
f(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "True", True)
f(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "tRUE", True)
f(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "TRUE", 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",
):
f(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",
):
f(NetworkConfig, "required_confirmations", "APE_ETHEREUM_REQUIRED_CONFIRMATIONS", "not a number", 42)

0 comments on commit 668142b

Please sign in to comment.