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
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
114 changes: 110 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,26 @@
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_console.config import ConsoleConfig
from ape_ethereum.ecosystem import (
BaseEthereumConfig,
EthereumConfig,
ForkedNetworkConfig,
NetworkConfig,
)
from ape_networks.config import CustomNetwork
from ape_node.provider import EthereumNetworkConfig, EthereumNodeConfig
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:
Expand Down Expand Up @@ -129,6 +147,94 @@ 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 hasattr(instance, attr)
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(
ApeConfig,
"dependencies",
"APE_DEPENDENCIES",
'[{"a":1},{"b":2},{"c":3}]',
[{"a": 1}, {"b": 2}, {"c": 3}],
)
run_test(CacheConfig, "size", "APE_CACHE_SIZE", "8627", 8627)
run_test(
CompileConfig, "include_dependencies", "APE_COMPILE_INCLUDE_DEPENDENCIES", "true", True
)
run_test(ConsoleConfig, "plugins", "APE_CONSOLE_PLUGINS", '["a","b","c"]', ["a", "b", "c"])
run_test(BaseEthereumConfig, "default_network", "APE_ETHEREUM_DEFAULT_NETWORK", "abe9e8293383")
run_test(
ForkedNetworkConfig, "upstream_provider", "APE_ETHEREUM_UPSTREAM_PROVIDER", "411236f13659"
)
run_test(
NetworkConfig, "required_confirmations", "APE_ETHEREUM_REQUIRED_CONFIRMATIONS", "6498", 6498
)
run_test(
lambda: CustomNetwork(name="", chain_id=0, ecosystem=""),
"base_ecosystem_plugin",
"APE_NETWORKS_BASE_ECOSYSTEM_PLUGIN",
"ea5010088102",
)
run_test(EthereumNetworkConfig, "mainnet", "APE_NODE_MAINNET", '{"a":"b"}', {"a": "b"})
run_test(EthereumNodeConfig, "executable", "APE_NODE_EXECUTABLE", "40613177e494")
run_test(ApeTestConfig, "balance", "APE_TEST_BALANCE", "4798", 4798)
run_test(CoverageConfig, "track", "APE_TEST_TRACK", "true", True)
run_test(CoverageReportsConfig, "terminal", "APE_TEST_TERMINAL", "false", False)
run_test(EthTesterProviderConfig, "chain_id", "APE_TEST_CHAIN_ID", "7925", 7925)
run_test(GasConfig, "reports", "APE_TEST_REPORTS", '["terminal"]', ["terminal"])
run_test(GasExclusion, "method_name", "APE_TEST_METHOD_NAME", "32aa54e3c5d2")
run_test(IsolationConfig, "enable_session", "APE_TEST_ENABLE_SESSION", "false", False)

# 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.
run_test(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "0", False)
run_test(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "False", False)
run_test(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "fALSE", False)
run_test(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "FALSE", False)
run_test(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "1", True)
run_test(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "True", True)
run_test(NetworkConfig, "is_mainnet", "APE_ETHEREUM_IS_MAINNET", "tRUE", True)
run_test(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",
):
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 +258,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
Loading