From 78151ff9802a53ba0c35e0854e4f9d81dd4948e3 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:05:43 +0530 Subject: [PATCH] fix(config): stringify pydantic error loc before join Config validation errors located at a list or tuple index crashed with 'TypeError: sequence item N: expected str instance, int found' because pydantic's error 'loc' mixes str field names with int sequence indices while _formatted_validation_errors joined them directly. Convert each loc part to str before joining so nested-list config errors (for example an invalid model_defaults.pre_statements entry) render the readable ConfigError instead of raising. Update test_load_model_defaults_validation_statements, which previously asserted the raw TypeError, to expect the readable ConfigError. Regression from #4511. Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> --- sqlmesh/utils/pydantic.py | 2 +- tests/core/test_config.py | 2 +- tests/utils/test_pydantic.py | 24 +++++++++++++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/sqlmesh/utils/pydantic.py b/sqlmesh/utils/pydantic.py index 43f389ef62..4e5cfc3dc1 100644 --- a/sqlmesh/utils/pydantic.py +++ b/sqlmesh/utils/pydantic.py @@ -242,7 +242,7 @@ def _formatted_validation_errors(error: pydantic.ValidationError) -> t.List[str] for e in error.errors(): msg = e["msg"] loc: t.Optional[t.Tuple] = e.get("loc") - loc_str = ".".join(loc) if loc else None + loc_str = ".".join(str(p) for p in loc) if loc else None result.append(f"Invalid field '{loc_str}':\n {msg}" if loc_str else msg) return result diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 7af556d6a3..becdc92df2 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -700,7 +700,7 @@ def test_load_model_defaults_validation_statements(tmp_path): """ ) - with pytest.raises(TypeError, match=r"expected str instance, int found"): + with pytest.raises(ConfigError, match=r"Invalid field 'model_defaults\.pre_statements\.0"): config = load_config_from_paths( Config, project_paths=[config_path], diff --git a/tests/utils/test_pydantic.py b/tests/utils/test_pydantic.py index b07d45acb1..9234589218 100644 --- a/tests/utils/test_pydantic.py +++ b/tests/utils/test_pydantic.py @@ -2,8 +2,14 @@ import pytest from functools import cached_property +import pydantic + from sqlmesh.utils.date import TimeLike, to_date, to_datetime -from sqlmesh.utils.pydantic import PydanticModel, get_concrete_types_from_typehint +from sqlmesh.utils.pydantic import ( + PydanticModel, + get_concrete_types_from_typehint, + validation_error_message, +) def test_datetime_date_serialization() -> None: @@ -87,3 +93,19 @@ class TestModel(PydanticModel): ) def test_get_concrete_types_from_typehint(input: t.Any, output: set[type]) -> None: assert get_concrete_types_from_typehint(input) == output + + +def test_validation_error_message_with_list_index_location() -> None: + class Inner(PydanticModel): + x: int + + class Outer(PydanticModel): + items: t.List[Inner] + + with pytest.raises(pydantic.ValidationError) as ex: + Outer(items=[{"x": "not_an_int"}]) + + # The error is located at a list index, so pydantic's loc mixes str field + # names with an int sequence index (e.g. ("items", 0, "x")). + message = validation_error_message(ex.value, "Invalid config:") + assert "Invalid field 'items.0.x'" in message