Skip to content

Commit c153052

Browse files
authored
fix: issues showing lines for plugin config validation errors (#2157)
1 parent 7c6a530 commit c153052

File tree

2 files changed

+102
-6
lines changed

2 files changed

+102
-6
lines changed

src/ape/api/config.py

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,19 @@ class MyConfig(PluginConfig):
4747
"""
4848

4949

50+
def _find_config_yaml_files(base_path: Path) -> list[Path]:
51+
"""
52+
Find all ape config file in the given path.
53+
"""
54+
found: list[Path] = []
55+
if (base_path / "ape-config.yaml").is_file():
56+
found.append(base_path / "ape-config.yaml")
57+
if (base_path / "ape-config.yml").is_file():
58+
found.append(base_path / "ape-config.yml")
59+
60+
return found
61+
62+
5063
class PluginConfig(BaseSettings):
5164
"""
5265
A base plugin configuration class. Each plugin that includes
@@ -56,7 +69,9 @@ class PluginConfig(BaseSettings):
5669
model_config = SettingsConfigDict(extra="allow")
5770

5871
@classmethod
59-
def from_overrides(cls, overrides: dict) -> "PluginConfig":
72+
def from_overrides(
73+
cls, overrides: dict, plugin_name: Optional[str] = None, project_path: Optional[Path] = None
74+
) -> "PluginConfig":
6075
default_values = cls().model_dump()
6176

6277
def update(root: dict, value_map: dict):
@@ -72,7 +87,54 @@ def update(root: dict, value_map: dict):
7287
try:
7388
return cls.model_validate(data)
7489
except ValidationError as err:
75-
raise ConfigError(str(err)) from err
90+
plugin_name = plugin_name or cls.__name__.replace("Config", "").lower()
91+
if problems := cls._find_plugin_config_problems(
92+
err, plugin_name, project_path=project_path
93+
):
94+
raise ConfigError(problems) from err
95+
else:
96+
raise ConfigError(str(err)) from err
97+
98+
@classmethod
99+
def _find_plugin_config_problems(
100+
cls, err: ValidationError, plugin_name: str, project_path: Optional[Path] = None
101+
) -> Optional[str]:
102+
# Attempt showing line-nos for failed plugin config validation.
103+
# This is trickier than root-level data since by this time, we
104+
# no longer are aware of which files are responsible for which config.
105+
ape = ManagerAccessMixin
106+
107+
# First, try checking the root config file ALONE. It is important to do
108+
# w/o any data from the project-level config to isolate the source of the problem.
109+
raw_global_data = ape.config_manager.global_config.model_dump(by_alias=True)
110+
if plugin_name in raw_global_data:
111+
try:
112+
cls.model_validate(raw_global_data[plugin_name])
113+
except Exception:
114+
if problems := cls._find_plugin_config_problems_from_file(
115+
err, ape.config_manager.DATA_FOLDER
116+
):
117+
return problems
118+
119+
# No issues found with global; try the local project.
120+
# NOTE: No need to isolate project-data w/o root-data because we have already
121+
# determined root-level data is OK.
122+
project_path = project_path or ape.local_project.path
123+
if problems := cls._find_plugin_config_problems_from_file(err, project_path):
124+
return problems
125+
126+
return None
127+
128+
@classmethod
129+
def _find_plugin_config_problems_from_file(
130+
cls, err: ValidationError, base_path: Path
131+
) -> Optional[str]:
132+
cfg_files = _find_config_yaml_files(base_path)
133+
for cfg_file in cfg_files:
134+
if problems := _get_problem_with_config(err.errors(), cfg_file):
135+
return problems
136+
137+
return None
76138

77139
@only_raise_attribute_error
78140
def __getattr__(self, attr_name: str) -> Any:
@@ -221,6 +283,12 @@ class ApeConfig(ExtraAttributesMixin, BaseSettings, ManagerAccessMixin):
221283
The top-level config.
222284
"""
223285

286+
def __init__(self, *args, **kwargs):
287+
project_path = kwargs.get("project")
288+
super(BaseSettings, self).__init__(*args, **kwargs)
289+
# NOTE: Cannot reference `self` at all until after super init.
290+
self._project_path = project_path
291+
224292
contracts_folder: Optional[str] = None
225293
"""
226294
The path to the folder containing the contract source files.
@@ -437,7 +505,9 @@ def get_plugin_config(self, name: str) -> Optional[PluginConfig]:
437505

438506
if cls != ConfigDict:
439507
# NOTE: Will raise if improperly provided keys
440-
config = cls.from_overrides(cfg)
508+
config = cls.from_overrides(
509+
cfg, plugin_name=plugin_name, project_path=self._project_path
510+
)
441511
else:
442512
# NOTE: Just use it directly as a dict if `ConfigDict` is passed
443513
config = cfg
@@ -470,15 +540,19 @@ def get_custom_ecosystem_config(self, name: str) -> Optional[PluginConfig]:
470540
from ape_ethereum import EthereumConfig
471541

472542
ethereum = cast(EthereumConfig, self.get_plugin_config("ethereum"))
473-
return ethereum.from_overrides(override)
543+
return ethereum.from_overrides(
544+
override, plugin_name=name, project_path=self._project_path
545+
)
474546

475547
return None
476548

477549
def get_unknown_config(self, name: str) -> PluginConfig:
478550
# This happens when a plugin is not installed but still configured.
479551
result = (self.__pydantic_extra__ or {}).get(name, PluginConfig())
480552
if isinstance(result, dict):
481-
return PluginConfig.from_overrides(result)
553+
return PluginConfig.from_overrides(
554+
result, plugin_name=name, project_path=self._project_path
555+
)
482556

483557
return result
484558

tests/functional/test_config.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,15 @@ def test_config_access():
243243
)
244244

245245

246-
def test_plugin_config_updates_when_default_is_empty_dict():
246+
def test_from_overrides():
247+
class MyConfig(PluginConfig):
248+
foo: int = 0
249+
250+
actual = MyConfig.from_overrides({"foo": 1})
251+
assert actual.foo == 1
252+
253+
254+
def test_from_overrides_updates_when_default_is_empty_dict():
247255
class SubConfig(PluginConfig):
248256
foo: int = 0
249257
bar: int = 1
@@ -256,6 +264,20 @@ class MyConfig(PluginConfig):
256264
assert actual.sub == {"baz": {"test": SubConfig(foo=5, bar=1)}}
257265

258266

267+
def test_from_overrides_shows_errors_in_project_config():
268+
class MyConfig(PluginConfig):
269+
foo: int = 0
270+
271+
with create_tempdir() as tmp_path:
272+
file = tmp_path / "ape-config.yaml"
273+
file.write_text("foo: [1,2,3]")
274+
275+
with pytest.raises(ConfigError) as err:
276+
_ = MyConfig.from_overrides({"foo": [1, 2, 3]}, project_path=tmp_path)
277+
278+
assert "-->1: foo: [1,2,3]" in str(err.value)
279+
280+
259281
@pytest.mark.parametrize("override_0,override_1", [(True, {"foo": 0}), ({"foo": 0}, True)])
260282
def test_plugin_config_with_union_dicts(override_0, override_1):
261283
class SubConfig(PluginConfig):

0 commit comments

Comments
 (0)