Skip to content

Commit 23b237c

Browse files
eakmanrqclaude
andauthored
Fix: harden ValidationInfo.data access for Pydantic 2.13 compat (#5763)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c199eff commit 23b237c

File tree

9 files changed

+52
-27
lines changed

9 files changed

+52
-27
lines changed

sqlmesh/core/config/connection.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
ValidationInfo,
3535
field_validator,
3636
model_validator,
37+
validation_data,
3738
validation_error_message,
3839
get_concrete_types_from_typehint,
3940
)
@@ -1081,7 +1082,7 @@ def validate_execution_project(
10811082
v: t.Optional[str],
10821083
info: ValidationInfo,
10831084
) -> t.Optional[str]:
1084-
if v and not info.data.get("project"):
1085+
if v and not validation_data(info).get("project"):
10851086
raise ConfigError(
10861087
"If the `execution_project` field is specified, you must also specify the `project` field to provide a default object location."
10871088
)
@@ -1093,7 +1094,7 @@ def validate_quota_project(
10931094
v: t.Optional[str],
10941095
info: ValidationInfo,
10951096
) -> t.Optional[str]:
1096-
if v and not info.data.get("project"):
1097+
if v and not validation_data(info).get("project"):
10971098
raise ConfigError(
10981099
"If the `quota_project` field is specified, you must also specify the `project` field to provide a default object location."
10991100
)

sqlmesh/core/environment.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ def _sanitize_name(cls, v: str) -> str:
5656
@classmethod
5757
def _validate_boolean_field(cls, v: t.Any, info: ValidationInfo) -> bool:
5858
if v is None:
59-
return info.field_name == "normalize_name"
59+
# Pydantic 2.13+ sets field_name to None during model_validate_json()
60+
return (info.field_name or "") == "normalize_name"
6061
return bool(v)
6162

6263
@t.overload

sqlmesh/core/metric/definition.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from sqlmesh.core.node import str_or_exp_to_str
1111
from sqlmesh.utils import UniqueKeyDict
1212
from sqlmesh.utils.errors import ConfigError
13-
from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator
13+
from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator, validation_data
1414

1515
MeasureAndDimTables = t.Tuple[str, t.Tuple[str, ...]]
1616

@@ -89,7 +89,7 @@ def _string_validator(cls, v: t.Any) -> t.Optional[str]:
8989
@field_validator("expression", mode="before")
9090
def _validate_expression(cls, v: t.Any, info: ValidationInfo) -> exp.Expr:
9191
if isinstance(v, str):
92-
dialect = info.data.get("dialect")
92+
dialect = validation_data(info).get("dialect")
9393
return d.parse_one(v, dialect=dialect)
9494
if isinstance(v, exp.Expr):
9595
return v

sqlmesh/core/model/common.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@
2121
prepare_env,
2222
serialize_env,
2323
)
24-
from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator, get_dialect
24+
from sqlmesh.utils.pydantic import (
25+
PydanticModel,
26+
ValidationInfo,
27+
field_validator,
28+
get_dialect,
29+
validation_data,
30+
)
2531

2632
if t.TYPE_CHECKING:
2733
from sqlglot.dialects.dialect import DialectType
@@ -479,7 +485,7 @@ def parse_expression(
479485
if callable(v):
480486
return v
481487

482-
dialect = info.data.get("dialect") if info else ""
488+
dialect = validation_data(info).get("dialect") if info else ""
483489

484490
if isinstance(v, list):
485491
return [
@@ -519,7 +525,7 @@ def parse_properties(
519525
if v is None:
520526
return v
521527

522-
dialect = info.data.get("dialect") if info else ""
528+
dialect = validation_data(info).get("dialect") if info else ""
523529

524530
if isinstance(v, str):
525531
v = d.parse_one(v, dialect=dialect)
@@ -557,8 +563,9 @@ def default_catalog(cls: t.Type, v: t.Any) -> t.Optional[str]:
557563

558564

559565
def depends_on(cls: t.Type, v: t.Any, info: ValidationInfo) -> t.Optional[t.Set[str]]:
560-
dialect = info.data.get("dialect")
561-
default_catalog = info.data.get("default_catalog")
566+
data = validation_data(info)
567+
dialect = data.get("dialect")
568+
default_catalog = data.get("default_catalog")
562569

563570
if isinstance(v, exp.Paren):
564571
v = v.unnest()

sqlmesh/core/model/meta.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
list_of_fields_validator,
4545
model_validator,
4646
get_dialect,
47+
validation_data,
4748
)
4849

4950
if t.TYPE_CHECKING:
@@ -135,7 +136,7 @@ def _func_call_validator(cls, v: t.Any, field: t.Any) -> t.Any:
135136

136137
@field_validator("tags", mode="before")
137138
def _value_or_tuple_validator(cls, v: t.Any, info: ValidationInfo) -> t.Any:
138-
return ensure_list(cls._validate_value_or_tuple(v, info.data))
139+
return ensure_list(cls._validate_value_or_tuple(v, validation_data(info)))
139140

140141
@classmethod
141142
def _validate_value_or_tuple(
@@ -164,7 +165,7 @@ def _normalize(value: t.Any) -> t.Any:
164165
@field_validator("table_format", "storage_format", mode="before")
165166
def _format_validator(cls, v: t.Any, info: ValidationInfo) -> t.Optional[str]:
166167
if isinstance(v, exp.Expr) and not (isinstance(v, (exp.Literal, exp.Identifier))):
167-
return v.sql(info.data.get("dialect"))
168+
return v.sql(validation_data(info).get("dialect"))
168169
return str_or_exp_to_str(v)
169170

170171
@field_validator("dialect", mode="before")
@@ -192,7 +193,7 @@ def _partition_and_cluster_validator(cls, v: t.Any, info: ValidationInfo) -> t.L
192193
if (
193194
isinstance(v, list)
194195
and all(isinstance(i, str) for i in v)
195-
and info.field_name == "partitioned_by_"
196+
and (info.field_name or "") == "partitioned_by_"
196197
):
197198
# this branch gets hit when we are deserializing from json because `partitioned_by` is stored as a List[str]
198199
# however, we should only invoke this if the list contains strings because this validator is also
@@ -205,7 +206,7 @@ def _partition_and_cluster_validator(cls, v: t.Any, info: ValidationInfo) -> t.L
205206
)
206207
v = parsed.this.expressions if isinstance(parsed.this, exp.Schema) else v
207208

208-
expressions = list_of_fields_validator(v, info.data)
209+
expressions = list_of_fields_validator(v, validation_data(info))
209210

210211
for expression in expressions:
211212
num_cols = len(list(expression.find_all(exp.Column)))
@@ -228,7 +229,7 @@ def _columns_validator(
228229
cls, v: t.Any, info: ValidationInfo
229230
) -> t.Optional[t.Dict[str, exp.DataType]]:
230231
columns_to_types = {}
231-
dialect = info.data.get("dialect")
232+
dialect = validation_data(info).get("dialect")
232233

233234
if isinstance(v, exp.Schema):
234235
for column in v.expressions:
@@ -280,7 +281,8 @@ def _columns_validator(
280281
def _column_descriptions_validator(
281282
cls, vs: t.Any, info: ValidationInfo
282283
) -> t.Optional[t.Dict[str, str]]:
283-
dialect = info.data.get("dialect")
284+
data = validation_data(info)
285+
dialect = data.get("dialect")
284286

285287
if vs is None:
286288
return None
@@ -302,23 +304,23 @@ def _column_descriptions_validator(
302304
for k, v in raw_col_descriptions.items()
303305
}
304306

305-
columns_to_types = info.data.get("columns_to_types_")
307+
columns_to_types = data.get("columns_to_types_")
306308
if columns_to_types:
307309
from sqlmesh.core.console import get_console
308310

309311
console = get_console()
310312
for column_name in list(col_descriptions):
311313
if column_name not in columns_to_types:
312314
console.log_warning(
313-
f"In model '{info.data['name']}', a description is provided for column '{column_name}' but it is not a column in the model."
315+
f"In model '{data.get('name', '<unknown>')}', a description is provided for column '{column_name}' but it is not a column in the model."
314316
)
315317
del col_descriptions[column_name]
316318

317319
return col_descriptions
318320

319321
@field_validator("grains", "references", mode="before")
320322
def _refs_validator(cls, vs: t.Any, info: ValidationInfo) -> t.List[exp.Expr]:
321-
dialect = info.data.get("dialect")
323+
dialect = validation_data(info).get("dialect")
322324

323325
if isinstance(vs, exp.Paren):
324326
vs = vs.unnest()

sqlmesh/core/state_sync/common.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pydantic_core.core_schema import ValidationInfo
1212
from sqlglot import exp
1313

14-
from sqlmesh.utils.pydantic import PydanticModel, field_validator
14+
from sqlmesh.utils.pydantic import PydanticModel, field_validator, validation_data
1515
from sqlmesh.core.environment import Environment, EnvironmentStatements, EnvironmentNamingInfo
1616
from sqlmesh.core.snapshot import (
1717
Snapshot,
@@ -269,7 +269,7 @@ class PromotionResult(PydanticModel):
269269
def _validate_removed_environment_naming_info(
270270
cls, v: t.Optional[EnvironmentNamingInfo], info: ValidationInfo
271271
) -> t.Optional[EnvironmentNamingInfo]:
272-
if v and not info.data.get("removed"):
272+
if v and not validation_data(info).get("removed"):
273273
raise ValueError("removed_environment_naming_info must be None if removed is empty")
274274
return v
275275

sqlmesh/core/user.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from enum import Enum
33

44
from sqlmesh.core.notification_target import BasicSMTPNotificationTarget, NotificationTarget
5-
from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator
5+
from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator, validation_data
66

77

88
class UserRole(str, Enum):
@@ -42,7 +42,7 @@ def validate_notification_targets(
4242
v: t.List[NotificationTarget],
4343
info: ValidationInfo,
4444
) -> t.List[NotificationTarget]:
45-
email = info.data["email"]
45+
email = validation_data(info).get("email")
4646
for target in v:
4747
if isinstance(target, BasicSMTPNotificationTarget) and target.recipients != {email}:
4848
raise ValueError("Recipient emails do not match user email")

sqlmesh/utils/pydantic.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ def field_serializer(*args: t.Any, **kwargs: t.Any) -> t.Callable[[t.Any], t.Any
4141
return pydantic.field_serializer(*args, **kwargs)
4242

4343

44+
def validation_data(info_or_data: t.Any) -> t.Dict[str, t.Any]:
45+
"""Safely extract the validated-data dict from a ValidationInfo, dict, or None.
46+
47+
Pydantic 2.13+ sets ValidationInfo.data to None during model_validate_json().
48+
This normalizes all inputs to a dict, returning an empty dict when data is unavailable.
49+
"""
50+
if isinstance(info_or_data, dict):
51+
return info_or_data
52+
if info_or_data is not None:
53+
return info_or_data.data or {}
54+
return {}
55+
56+
4457
def get_dialect(values: t.Any) -> str:
4558
"""Extracts dialect from a dict or pydantic obj, defaulting to the globally set dialect.
4659
@@ -52,7 +65,7 @@ def get_dialect(values: t.Any) -> str:
5265

5366
from sqlmesh.core.model import model
5467

55-
dialect = (values if isinstance(values, dict) else values.data).get("dialect")
68+
dialect = validation_data(values).get("dialect")
5669
return model._dialect if dialect is None else dialect # type: ignore
5770

5871

web/server/models.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
SnapshotId,
2020
)
2121
from sqlmesh.utils.date import TimeLike, now_timestamp
22-
from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator
22+
from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator, validation_data
2323

2424
SUPPORTED_EXTENSIONS = {".py", ".sql", ".yaml", ".yml", ".csv"}
2525

@@ -117,8 +117,9 @@ class File(PydanticModel):
117117

118118
@field_validator("extension", mode="before")
119119
def default_extension(cls, v: str, info: ValidationInfo) -> str:
120-
if "name" in info.data:
121-
return pathlib.Path(info.data["name"]).suffix
120+
data = validation_data(info)
121+
if "name" in data:
122+
return pathlib.Path(data["name"]).suffix
122123
return v
123124

124125

0 commit comments

Comments
 (0)