Skip to content

Commit 9b98ac4

Browse files
authored
Chore: Improve error messages returned by pydantic (#4430)
1 parent c5c1562 commit 9b98ac4

File tree

13 files changed

+126
-69
lines changed

13 files changed

+126
-69
lines changed

sqlmesh/core/config/connection.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@
2525
from sqlmesh.core.engine_adapter.shared import CatalogSupport
2626
from sqlmesh.core.engine_adapter import EngineAdapter
2727
from sqlmesh.utils.errors import ConfigError
28-
from sqlmesh.utils.pydantic import ValidationInfo, field_validator, model_validator
28+
from sqlmesh.utils.pydantic import (
29+
ValidationInfo,
30+
field_validator,
31+
model_validator,
32+
validation_error_message,
33+
)
2934
from sqlmesh.utils.aws import validate_s3_uri
3035

3136
if t.TYPE_CHECKING:
@@ -1846,7 +1851,13 @@ def _connection_config_validator(
18461851
) -> ConnectionConfig | None:
18471852
if v is None or isinstance(v, ConnectionConfig):
18481853
return v
1849-
return parse_connection_config(v)
1854+
try:
1855+
return parse_connection_config(v)
1856+
except pydantic.ValidationError as e:
1857+
raise ConfigError(
1858+
validation_error_message(e, f"Invalid '{v['type']}' connection config:")
1859+
+ "\n\nVerify your config.yaml and environment variables."
1860+
)
18501861

18511862

18521863
connection_config_validator: t.Callable = field_validator(

sqlmesh/core/config/loader.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import typing as t
66
from pathlib import Path
77

8+
from pydantic import ValidationError
89
from sqlglot.helper import ensure_list
910

1011
from sqlmesh.core import constants as c
@@ -13,6 +14,7 @@
1314
from sqlmesh.utils import env_vars, merge_dicts, sys_path
1415
from sqlmesh.utils.errors import ConfigError
1516
from sqlmesh.utils.metaprogramming import import_python_file
17+
from sqlmesh.utils.pydantic import validation_error_message
1618
from sqlmesh.utils.yaml import load as yaml_load
1719

1820
C = t.TypeVar("C", bound=Config)
@@ -99,9 +101,15 @@ def load_config_from_paths(
99101
)
100102
non_python_configs.append(load_config_from_yaml(path))
101103
elif extension == "py":
102-
python_config = load_config_from_python_module(
103-
config_type, path, config_name=config_name
104-
)
104+
try:
105+
python_config = load_config_from_python_module(
106+
config_type, path, config_name=config_name
107+
)
108+
except ValidationError as e:
109+
raise ConfigError(
110+
validation_error_message(e, f"Invalid project config '{config_name}':")
111+
+ "\n\nVerify your config.py."
112+
)
105113
else:
106114
raise ConfigError(
107115
f"Unsupported config file extension '{extension}' in config file '{path}'."
@@ -126,7 +134,13 @@ def load_config_from_paths(
126134
f"'{default}' is not a valid model default configuration key. Please remove it from the `model_defaults` specification in your config file."
127135
)
128136

129-
non_python_config = config_type.parse_obj(non_python_config_dict)
137+
try:
138+
non_python_config = config_type.parse_obj(non_python_config_dict)
139+
except ValidationError as e:
140+
raise ConfigError(
141+
validation_error_message(e, "Invalid project config:")
142+
+ "\n\nVerify your config.yaml and environment variables."
143+
)
130144

131145
no_dialect_err_msg = "Default model SQL dialect is a required configuration parameter. Set it in the `model_defaults` `dialect` key in your config file."
132146
if python_config:

sqlmesh/core/config/scheduler.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import abc
44
import typing as t
55

6-
from pydantic import Field
6+
from pydantic import Field, ValidationError
77

88
from sqlglot.helper import subclasses
99
from sqlmesh.core.config.base import BaseConfig
@@ -16,7 +16,7 @@
1616
from sqlmesh.core.state_sync import EngineAdapterStateSync, StateSync
1717
from sqlmesh.utils.errors import ConfigError
1818
from sqlmesh.utils.hashing import md5
19-
from sqlmesh.utils.pydantic import field_validator
19+
from sqlmesh.utils.pydantic import field_validator, validation_error_message
2020

2121
if t.TYPE_CHECKING:
2222
from sqlmesh.core.context import GenericContext
@@ -159,7 +159,13 @@ def _scheduler_config_validator(
159159
if scheduler_type not in SCHEDULER_CONFIG_TO_TYPE:
160160
raise ConfigError(f"Unknown scheduler type '{scheduler_type}'.")
161161

162-
return SCHEDULER_CONFIG_TO_TYPE[scheduler_type](**v)
162+
try:
163+
return SCHEDULER_CONFIG_TO_TYPE[scheduler_type](**v)
164+
except ValidationError as e:
165+
raise ConfigError(
166+
validation_error_message(e, f"Invalid '{scheduler_type}' scheduler config:")
167+
+ "\n\nVerify your config.yaml and environment variables."
168+
)
163169

164170

165171
scheduler_config_validator = field_validator(

sqlmesh/core/loader.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from collections import Counter, defaultdict
1212
from dataclasses import dataclass
1313
from pathlib import Path
14+
from pydantic import ValidationError
1415

1516
from sqlglot.errors import SqlglotError
1617
from sqlglot import exp
@@ -39,6 +40,7 @@
3940
from sqlmesh.utils.errors import ConfigError
4041
from sqlmesh.utils.jinja import JinjaMacroRegistry, MacroExtractor
4142
from sqlmesh.utils.metaprogramming import import_python_file
43+
from sqlmesh.utils.pydantic import validation_error_message
4244
from sqlmesh.utils.yaml import YAML, load as yaml_load
4345

4446

@@ -241,7 +243,7 @@ def _load(path: Path) -> t.List[Model]:
241243
for row in YAML().load(file.read())
242244
]
243245
except Exception as ex:
244-
self._raise_failed_to_load_model_error(path, str(ex))
246+
self._raise_failed_to_load_model_error(path, ex)
245247
raise
246248

247249
for path in paths_to_load:
@@ -372,8 +374,11 @@ def _get_variables(self, gateway_name: t.Optional[str] = None) -> t.Dict[str, t.
372374

373375
return self._variables_by_gateway[gateway_name]
374376

375-
def _raise_failed_to_load_model_error(self, path: Path, message: str) -> None:
376-
raise ConfigError(f"Failed to load model definition at '{path}'.\n{message}")
377+
def _raise_failed_to_load_model_error(self, path: Path, error: t.Union[str, Exception]) -> None:
378+
base_message = f"Failed to load model definition at '{path}':"
379+
if isinstance(error, ValidationError):
380+
raise ConfigError(validation_error_message(error, base_message))
381+
raise ConfigError(f"{base_message}\n {error}")
377382

378383

379384
class SqlMeshLoader(Loader):
@@ -496,7 +501,7 @@ def _load() -> t.List[Model]:
496501
default_catalog_per_gateway=self.context.default_catalog_per_gateway,
497502
)
498503
except Exception as ex:
499-
self._raise_failed_to_load_model_error(path, str(ex))
504+
self._raise_failed_to_load_model_error(path, ex)
500505
raise
501506

502507
for model in cache.get_or_load_models(path, _load):
@@ -561,7 +566,7 @@ def _load_python_models(
561566
if model.enabled:
562567
models[model.fqn] = model
563568
except Exception as ex:
564-
self._raise_failed_to_load_model_error(path, str(ex))
569+
self._raise_failed_to_load_model_error(path, ex)
565570

566571
finally:
567572
model_registry._dialect = None

sqlmesh/core/model/common.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
prepare_env,
2121
serialize_env,
2222
)
23-
from sqlmesh.utils.pydantic import ValidationInfo, field_validator
23+
from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator
2424

2525
if t.TYPE_CHECKING:
2626
from sqlglot.dialects.dialect import DialectType
@@ -260,6 +260,22 @@ def get_first_arg(keyword_arg_name: str) -> t.Any:
260260
return depends_on, used_variables
261261

262262

263+
def validate_extra_and_required_fields(
264+
klass: t.Type[PydanticModel],
265+
provided_fields: t.Set[str],
266+
entity_name: str,
267+
) -> None:
268+
missing_required_fields = klass.missing_required_fields(provided_fields)
269+
if missing_required_fields:
270+
raise_config_error(
271+
f"Missing required fields {missing_required_fields} in the {entity_name}"
272+
)
273+
274+
extra_fields = klass.extra_fields(provided_fields)
275+
if extra_fields:
276+
raise_config_error(f"Invalid extra fields {extra_fields} in the {entity_name}")
277+
278+
263279
def single_value_or_tuple(values: t.Sequence) -> exp.Identifier | exp.Tuple:
264280
return (
265281
exp.to_identifier(values[0])

sqlmesh/core/model/definition.py

Lines changed: 20 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
parse_dependencies,
3232
single_value_or_tuple,
3333
sorted_python_env_payloads,
34+
validate_extra_and_required_fields,
3435
)
3536
from sqlmesh.core.model.meta import ModelMeta, FunctionCall
3637
from sqlmesh.core.model.kind import (
@@ -2190,15 +2191,11 @@ def load_sql_based_model(
21902191
seed_properties = {
21912192
p.name.lower(): p.args.get("value") for p in common_kwargs.pop("kind").expressions
21922193
}
2193-
try:
2194-
return create_seed_model(
2195-
name,
2196-
SeedKind(**seed_properties),
2197-
**common_kwargs,
2198-
)
2199-
except Exception as ex:
2200-
raise_config_error(str(ex), path)
2201-
raise
2194+
return create_seed_model(
2195+
name,
2196+
SeedKind(**seed_properties),
2197+
**common_kwargs,
2198+
)
22022199

22032200

22042201
def create_sql_model(
@@ -2398,7 +2395,9 @@ def _create_model(
23982395
blueprint_variables: t.Optional[t.Dict[str, t.Any]] = None,
23992396
**kwargs: t.Any,
24002397
) -> Model:
2401-
_validate_model_fields(klass, {"name", *kwargs} - {"grain", "table_properties"}, path)
2398+
validate_extra_and_required_fields(
2399+
klass, {"name", *kwargs} - {"grain", "table_properties"}, "model definition"
2400+
)
24022401

24032402
for prop in PROPERTIES:
24042403
kwargs[prop] = _resolve_properties((defaults or {}).get(prop), kwargs.get(prop))
@@ -2457,21 +2456,17 @@ def _create_model(
24572456
for jinja_macro in jinja_macros.root_macros.values():
24582457
used_variables.update(extract_macro_references_and_variables(jinja_macro.definition)[1])
24592458

2460-
try:
2461-
model = klass(
2462-
name=name,
2463-
**{
2464-
**(defaults or {}),
2465-
"jinja_macros": jinja_macros or JinjaMacroRegistry(),
2466-
"dialect": dialect,
2467-
"depends_on": depends_on,
2468-
"physical_schema_override": physical_schema_override,
2469-
**kwargs,
2470-
},
2471-
)
2472-
except Exception as ex:
2473-
raise_config_error(str(ex), location=path)
2474-
raise
2459+
model = klass(
2460+
name=name,
2461+
**{
2462+
**(defaults or {}),
2463+
"jinja_macros": jinja_macros or JinjaMacroRegistry(),
2464+
"dialect": dialect,
2465+
"depends_on": depends_on,
2466+
"physical_schema_override": physical_schema_override,
2467+
**kwargs,
2468+
},
2469+
)
24752470

24762471
audit_definitions = {
24772472
**(audit_definitions or {}),
@@ -2636,19 +2631,6 @@ def _resolve_properties(
26362631
return None
26372632

26382633

2639-
def _validate_model_fields(klass: t.Type[_Model], provided_fields: t.Set[str], path: Path) -> None:
2640-
missing_required_fields = klass.missing_required_fields(provided_fields)
2641-
if missing_required_fields:
2642-
raise_config_error(
2643-
f"Missing required fields {missing_required_fields} in the model definition",
2644-
path,
2645-
)
2646-
2647-
extra_fields = klass.extra_fields(provided_fields)
2648-
if extra_fields:
2649-
raise_config_error(f"Invalid extra fields {extra_fields} in the model definition", path)
2650-
2651-
26522634
def _list_of_calls_to_exp(value: t.List[t.Tuple[str, t.Dict[str, t.Any]]]) -> exp.Expression:
26532635
return exp.Tuple(
26542636
expressions=[

sqlmesh/core/model/kind.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
from sqlglot.time import format_time
1313

1414
from sqlmesh.core import dialect as d
15-
from sqlmesh.core.model.common import parse_properties, properties_validator
15+
from sqlmesh.core.model.common import (
16+
parse_properties,
17+
properties_validator,
18+
validate_extra_and_required_fields,
19+
)
1620
from sqlmesh.core.model.seed import CsvSettings
1721
from sqlmesh.utils.errors import ConfigError
1822
from sqlmesh.utils.pydantic import (
@@ -1011,6 +1015,7 @@ def create_model_kind(v: t.Any, dialect: str, defaults: t.Dict[str, t.Any]) -> M
10111015
actual_kind_type, _ = custom_materialization
10121016
return actual_kind_type(**props)
10131017

1018+
validate_extra_and_required_fields(kind_type, set(props), f"model kind '{name}'")
10141019
return kind_type(**props)
10151020

10161021
name = (v.name if isinstance(v, exp.Expression) else str(v)).upper()

sqlmesh/core/model/meta.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ def _root_validator(self) -> Self:
355355
and not (kind.is_view and kind.materialized)
356356
):
357357
name = field[:-1] if field.endswith("_") else field
358-
raise ValueError(f"{name} field cannot be set for {kind} models")
358+
raise ValueError(f"{name} field cannot be set for {kind.name} models")
359359
if kind.is_incremental_by_partition and not getattr(self, "partitioned_by_", None):
360360
raise ValueError(f"partitioned_by field is required for {kind.name} models")
361361

sqlmesh/utils/pydantic.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,20 @@ def positive_int_validator(v: t.Any) -> int:
211211
return v
212212

213213

214+
def validation_error_message(error: pydantic.ValidationError, base: str) -> str:
215+
errors = "\n ".join(_formatted_validation_errors(error))
216+
return f"{base}\n {errors}"
217+
218+
219+
def _formatted_validation_errors(error: pydantic.ValidationError) -> t.List[str]:
220+
result = []
221+
for e in error.errors():
222+
msg = e["msg"]
223+
loc: t.Optional[t.Tuple] = e.get("loc")
224+
result.append(f"Invalid field '{loc[0]}':\n {msg}" if loc else msg)
225+
return result
226+
227+
214228
def _get_field(
215229
v: t.Any,
216230
values: t.Any,
@@ -281,6 +295,9 @@ def cron_validator(v: t.Any) -> str:
281295

282296
from croniter import CroniterBadCronError, croniter
283297

298+
if not isinstance(v, str):
299+
raise ValueError(f"Invalid cron expression '{v}'. Value must be a string.")
300+
284301
try:
285302
croniter(v)
286303
except CroniterBadCronError:

tests/core/test_connection_config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -973,13 +973,13 @@ def test_databricks(make_config):
973973
assert oauth_u2m_config.oauth_client_secret is None
974974

975975
# auth_type must match the AuthType enum if specified
976-
with pytest.raises(ValueError, match=r".*nonexist does not match a valid option.*"):
976+
with pytest.raises(ConfigError, match=r".*nonexist does not match a valid option.*"):
977977
make_config(
978978
type="databricks", server_hostname="dbc-test.cloud.databricks.com", auth_type="nonexist"
979979
)
980980

981981
# if client_secret is specified, client_id must also be specified
982-
with pytest.raises(ValueError, match=r"`oauth_client_id` is required.*"):
982+
with pytest.raises(ConfigError, match=r"`oauth_client_id` is required.*"):
983983
make_config(
984984
type="databricks",
985985
server_hostname="dbc-test.cloud.databricks.com",
@@ -988,7 +988,7 @@ def test_databricks(make_config):
988988
)
989989

990990
# http_path is still required when auth_type is specified
991-
with pytest.raises(ValueError, match=r"`http_path` is still required.*"):
991+
with pytest.raises(ConfigError, match=r"`http_path` is still required.*"):
992992
make_config(
993993
type="databricks",
994994
server_hostname="dbc-test.cloud.databricks.com",

0 commit comments

Comments
 (0)