From 114781d4db7717de687ce4bd709c9d3660250af5 Mon Sep 17 00:00:00 2001 From: antazoey Date: Thu, 13 Jun 2024 13:07:57 -0500 Subject: [PATCH] feat: show config error linenos (#2134) --- src/ape/_cli.py | 18 +++++++- src/ape/api/config.py | 72 ++++++++++++++++++++++++++++-- src/ape/managers/project.py | 14 ++++++ tests/integration/cli/test_misc.py | 26 +++++++++++ 4 files changed, 126 insertions(+), 4 deletions(-) diff --git a/src/ape/_cli.py b/src/ape/_cli.py index c7ba257d24..c9ffcad46a 100644 --- a/src/ape/_cli.py +++ b/src/ape/_cli.py @@ -9,10 +9,11 @@ from typing import Any, Optional import click +import rich import yaml from ape.cli import ape_cli_context -from ape.exceptions import Abort, ApeException, handle_ape_exception +from ape.exceptions import Abort, ApeException, ConfigError, handle_ape_exception from ape.logging import logger from ape.plugins._utils import PluginMetadataList, clean_plugin_name from ape.utils.basemodel import ManagerAccessMixin @@ -35,10 +36,25 @@ def display_config(ctx, param, value): ctx.exit() # NOTE: Must exit to bypass running ApeCLI +def _validate_config(): + try: + _ = ManagerAccessMixin.local_project.config + except ConfigError as err: + rich.print(err) + # Exit now to avoid weird problems. + sys.exit(1) + + class ApeCLI(click.MultiCommand): _commands: Optional[dict] = None _CLI_GROUP_NAME = "ape_cli_subcommands" + def __init__(self, *args, **kwargs): + # Validate the config before any argument parsing, + # as arguments may utilize config. + _validate_config() + super().__init__(*args, **kwargs) + def format_commands(self, ctx, formatter) -> None: commands = [] for subcommand in self.list_commands(ctx): diff --git a/src/ape/api/config.py b/src/ape/api/config.py index e484f9448f..2f2aa9026c 100644 --- a/src/ape/api/config.py +++ b/src/ape/api/config.py @@ -6,13 +6,14 @@ from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast import yaml -from ethpm_types import PackageManifest, PackageMeta -from pydantic import ConfigDict, Field, model_validator +from ethpm_types import PackageManifest, PackageMeta, Source +from pydantic import ConfigDict, Field, ValidationError, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from ape.exceptions import ConfigError from ape.logging import logger from ape.types import AddressType +from ape.utils import clean_path from ape.utils.basemodel import ( ExtraAttributesMixin, ExtraModelAttributes, @@ -274,7 +275,72 @@ def validate_file(cls, path: Path, **overrides) -> "ApeConfig": # relative from. data["project"] = path.parent - return cls.model_validate(data) + try: + return cls.model_validate(data) + except ValidationError as err: + if path.suffix == ".json": + # TODO: Support JSON configs here. + raise # The validation error as-is + + # Several errors may have been collected from the config. + all_errors = err.errors() + # Attempt to find line numbers in the config matching. + cfg_content = Source(content=path.read_text(encoding="utf8")).content + if not cfg_content: + # Likely won't happen? + raise # The validation error as-is + + err_map: dict = {} + for error in all_errors: + if not (location := error.get("loc")): + continue + + lineno = None + loc_idx = 0 + depth = len(location) + for no, line in cfg_content.items(): + loc = location[loc_idx] + if not line.lstrip().startswith(f"{loc}:"): + continue + + # Look for the next location up the tree. + loc_idx += 1 + if loc_idx < depth: + continue + + # Found. + lineno = no + break + + if lineno is not None and loc_idx >= depth: + err_map[lineno] = error + # else: we looped through the whole file and didn't find anything. + + if not err_map: + # raise ValidationError as-is (no line numbers found for some reason). + raise + + error_strs: list[str] = [] + for line_no, cfg_err in err_map.items(): + sub_message = cfg_err.get("msg", cfg_err) + line_before = line_no - 1 if line_no > 1 else None + line_after = line_no + 1 if line_no < len(cfg_content) else None + lines = [] + if line_before is not None: + lines.append(f" {line_before}: {cfg_content[line_before]}") + lines.append(f"-->{line_no}: {cfg_content[line_no]}") + if line_after is not None: + lines.append(f" {line_after}: {cfg_content[line_after]}") + + file_preview = "\n".join(lines) + sub_message = f"{sub_message}\n{file_preview}\n" + error_strs.append(sub_message) + + # NOTE: Using reversed because later pydantic errors + # appear earlier in the list. + final_msg = "\n".join(reversed(error_strs)).strip() + final_msg = f"'{clean_path(path)}' is invalid!\n{final_msg}" + raise ConfigError(final_msg) @classmethod def from_manifest(cls, manifest: PackageManifest, **overrides) -> "ApeConfig": diff --git a/src/ape/managers/project.py b/src/ape/managers/project.py index 44f78b877e..51aecfcd9c 100644 --- a/src/ape/managers/project.py +++ b/src/ape/managers/project.py @@ -2431,6 +2431,20 @@ def clean(self): self._manifest = PackageManifest() self.sources._path_cache = None + self._clear_cached_config() + + def reload_config(self): + """ + Reload the local ape-config.yaml file. + This is useful if the file was modified in the + active python session. + """ + self._clear_cached_config() + _ = self.config + + def _clear_cached_config(self): + if "config" in self.__dict__: + del self.__dict__["config"] def _create_contract_source(self, contract_type: ContractType) -> Optional[ContractSource]: if not (source_id := contract_type.source_id): diff --git a/tests/integration/cli/test_misc.py b/tests/integration/cli/test_misc.py index 49933d4b8f..c5451ec675 100644 --- a/tests/integration/cli/test_misc.py +++ b/tests/integration/cli/test_misc.py @@ -1,7 +1,10 @@ +import os import re import pytest +from ape import Project +from tests.conftest import ApeSubprocessRunner from tests.integration.cli.utils import run_once @@ -30,3 +33,26 @@ def test_help(ape_cli, runner): rf"test\s*Launches pytest{anything}" ) assert re.match(expected.strip(), result.output) + + +@run_once +def test_invalid_config(): + # Using subprocess runner so we re-hit the init of the cmd. + runner = ApeSubprocessRunner("ape") + here = os.curdir + with Project.create_temporary_project() as tmp: + cfgfile = tmp.path / "ape-config.yaml" + # Name is invalid! + cfgfile.write_text("name:\n {asdf}") + + os.chdir(tmp.path) + result = runner.invoke("--help") + os.chdir(here) + + expected = """ +Input should be a valid string +-->1: name: + 2: {asdf} +""".strip() + assert result.exit_code != 0 + assert expected in result.output