From 4fc2217d9c26248698ebc82e31471e387b45e48f Mon Sep 17 00:00:00 2001 From: eakmanrq <6326532+eakmanrq@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:08:37 -0700 Subject: [PATCH 1/2] Fix: handle None ValidationInfo.data in get_dialect for Pydantic 2.13 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: eakmanrq <6326532+eakmanrq@users.noreply.github.com> --- sqlmesh/utils/pydantic.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sqlmesh/utils/pydantic.py b/sqlmesh/utils/pydantic.py index 5e3e5f979b..216fa7ddaf 100644 --- a/sqlmesh/utils/pydantic.py +++ b/sqlmesh/utils/pydantic.py @@ -52,7 +52,13 @@ def get_dialect(values: t.Any) -> str: from sqlmesh.core.model import model - dialect = (values if isinstance(values, dict) else values.data).get("dialect") + if isinstance(values, dict): + data = values + elif values is not None: + data = values.data + else: + data = None + dialect = data.get("dialect") if data is not None else None return model._dialect if dialect is None else dialect # type: ignore From 87749bc0850b60616810743b43dfe31d5923c17a Mon Sep 17 00:00:00 2001 From: eakmanrq <6326532+eakmanrq@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:47:55 -0700 Subject: [PATCH 2/2] Fix: harden all ValidationInfo.data access for Pydantic 2.13 Pydantic 2.13 sets ValidationInfo.data and field_name to None during model_validate_json(). Add validation_data() helper to centralize safe data extraction, and apply it across all field validators that access info.data directly. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: eakmanrq <6326532+eakmanrq@users.noreply.github.com> --- sqlmesh/core/config/connection.py | 5 +++-- sqlmesh/core/environment.py | 3 ++- sqlmesh/core/metric/definition.py | 4 ++-- sqlmesh/core/model/common.py | 17 ++++++++++++----- sqlmesh/core/model/meta.py | 20 +++++++++++--------- sqlmesh/core/state_sync/common.py | 4 ++-- sqlmesh/core/user.py | 4 ++-- sqlmesh/utils/pydantic.py | 21 ++++++++++++++------- web/server/models.py | 7 ++++--- 9 files changed, 52 insertions(+), 33 deletions(-) diff --git a/sqlmesh/core/config/connection.py b/sqlmesh/core/config/connection.py index 07e8be2908..d930537711 100644 --- a/sqlmesh/core/config/connection.py +++ b/sqlmesh/core/config/connection.py @@ -34,6 +34,7 @@ ValidationInfo, field_validator, model_validator, + validation_data, validation_error_message, get_concrete_types_from_typehint, ) @@ -1081,7 +1082,7 @@ def validate_execution_project( v: t.Optional[str], info: ValidationInfo, ) -> t.Optional[str]: - if v and not info.data.get("project"): + if v and not validation_data(info).get("project"): raise ConfigError( "If the `execution_project` field is specified, you must also specify the `project` field to provide a default object location." ) @@ -1093,7 +1094,7 @@ def validate_quota_project( v: t.Optional[str], info: ValidationInfo, ) -> t.Optional[str]: - if v and not info.data.get("project"): + if v and not validation_data(info).get("project"): raise ConfigError( "If the `quota_project` field is specified, you must also specify the `project` field to provide a default object location." ) diff --git a/sqlmesh/core/environment.py b/sqlmesh/core/environment.py index 4a1f417468..4594dc120d 100644 --- a/sqlmesh/core/environment.py +++ b/sqlmesh/core/environment.py @@ -56,7 +56,8 @@ def _sanitize_name(cls, v: str) -> str: @classmethod def _validate_boolean_field(cls, v: t.Any, info: ValidationInfo) -> bool: if v is None: - return info.field_name == "normalize_name" + # Pydantic 2.13+ sets field_name to None during model_validate_json() + return (info.field_name or "") == "normalize_name" return bool(v) @t.overload diff --git a/sqlmesh/core/metric/definition.py b/sqlmesh/core/metric/definition.py index 70f10b2347..6119a883ed 100644 --- a/sqlmesh/core/metric/definition.py +++ b/sqlmesh/core/metric/definition.py @@ -10,7 +10,7 @@ from sqlmesh.core.node import str_or_exp_to_str from sqlmesh.utils import UniqueKeyDict from sqlmesh.utils.errors import ConfigError -from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator +from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator, validation_data MeasureAndDimTables = t.Tuple[str, t.Tuple[str, ...]] @@ -89,7 +89,7 @@ def _string_validator(cls, v: t.Any) -> t.Optional[str]: @field_validator("expression", mode="before") def _validate_expression(cls, v: t.Any, info: ValidationInfo) -> exp.Expr: if isinstance(v, str): - dialect = info.data.get("dialect") + dialect = validation_data(info).get("dialect") return d.parse_one(v, dialect=dialect) if isinstance(v, exp.Expr): return v diff --git a/sqlmesh/core/model/common.py b/sqlmesh/core/model/common.py index ccde7624bd..c75531afb8 100644 --- a/sqlmesh/core/model/common.py +++ b/sqlmesh/core/model/common.py @@ -21,7 +21,13 @@ prepare_env, serialize_env, ) -from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator, get_dialect +from sqlmesh.utils.pydantic import ( + PydanticModel, + ValidationInfo, + field_validator, + get_dialect, + validation_data, +) if t.TYPE_CHECKING: from sqlglot.dialects.dialect import DialectType @@ -479,7 +485,7 @@ def parse_expression( if callable(v): return v - dialect = info.data.get("dialect") if info else "" + dialect = validation_data(info).get("dialect") if info else "" if isinstance(v, list): return [ @@ -519,7 +525,7 @@ def parse_properties( if v is None: return v - dialect = info.data.get("dialect") if info else "" + dialect = validation_data(info).get("dialect") if info else "" if isinstance(v, str): v = d.parse_one(v, dialect=dialect) @@ -557,8 +563,9 @@ def default_catalog(cls: t.Type, v: t.Any) -> t.Optional[str]: def depends_on(cls: t.Type, v: t.Any, info: ValidationInfo) -> t.Optional[t.Set[str]]: - dialect = info.data.get("dialect") - default_catalog = info.data.get("default_catalog") + data = validation_data(info) + dialect = data.get("dialect") + default_catalog = data.get("default_catalog") if isinstance(v, exp.Paren): v = v.unnest() diff --git a/sqlmesh/core/model/meta.py b/sqlmesh/core/model/meta.py index a73d6d871a..d5a93c459c 100644 --- a/sqlmesh/core/model/meta.py +++ b/sqlmesh/core/model/meta.py @@ -44,6 +44,7 @@ list_of_fields_validator, model_validator, get_dialect, + validation_data, ) if t.TYPE_CHECKING: @@ -135,7 +136,7 @@ def _func_call_validator(cls, v: t.Any, field: t.Any) -> t.Any: @field_validator("tags", mode="before") def _value_or_tuple_validator(cls, v: t.Any, info: ValidationInfo) -> t.Any: - return ensure_list(cls._validate_value_or_tuple(v, info.data)) + return ensure_list(cls._validate_value_or_tuple(v, validation_data(info))) @classmethod def _validate_value_or_tuple( @@ -164,7 +165,7 @@ def _normalize(value: t.Any) -> t.Any: @field_validator("table_format", "storage_format", mode="before") def _format_validator(cls, v: t.Any, info: ValidationInfo) -> t.Optional[str]: if isinstance(v, exp.Expr) and not (isinstance(v, (exp.Literal, exp.Identifier))): - return v.sql(info.data.get("dialect")) + return v.sql(validation_data(info).get("dialect")) return str_or_exp_to_str(v) @field_validator("dialect", mode="before") @@ -192,7 +193,7 @@ def _partition_and_cluster_validator(cls, v: t.Any, info: ValidationInfo) -> t.L if ( isinstance(v, list) and all(isinstance(i, str) for i in v) - and info.field_name == "partitioned_by_" + and (info.field_name or "") == "partitioned_by_" ): # this branch gets hit when we are deserializing from json because `partitioned_by` is stored as a List[str] # 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 ) v = parsed.this.expressions if isinstance(parsed.this, exp.Schema) else v - expressions = list_of_fields_validator(v, info.data) + expressions = list_of_fields_validator(v, validation_data(info)) for expression in expressions: num_cols = len(list(expression.find_all(exp.Column))) @@ -228,7 +229,7 @@ def _columns_validator( cls, v: t.Any, info: ValidationInfo ) -> t.Optional[t.Dict[str, exp.DataType]]: columns_to_types = {} - dialect = info.data.get("dialect") + dialect = validation_data(info).get("dialect") if isinstance(v, exp.Schema): for column in v.expressions: @@ -280,7 +281,8 @@ def _columns_validator( def _column_descriptions_validator( cls, vs: t.Any, info: ValidationInfo ) -> t.Optional[t.Dict[str, str]]: - dialect = info.data.get("dialect") + data = validation_data(info) + dialect = data.get("dialect") if vs is None: return None @@ -302,7 +304,7 @@ def _column_descriptions_validator( for k, v in raw_col_descriptions.items() } - columns_to_types = info.data.get("columns_to_types_") + columns_to_types = data.get("columns_to_types_") if columns_to_types: from sqlmesh.core.console import get_console @@ -310,7 +312,7 @@ def _column_descriptions_validator( for column_name in list(col_descriptions): if column_name not in columns_to_types: console.log_warning( - f"In model '{info.data['name']}', a description is provided for column '{column_name}' but it is not a column in the model." + f"In model '{data.get('name', '')}', a description is provided for column '{column_name}' but it is not a column in the model." ) del col_descriptions[column_name] @@ -318,7 +320,7 @@ def _column_descriptions_validator( @field_validator("grains", "references", mode="before") def _refs_validator(cls, vs: t.Any, info: ValidationInfo) -> t.List[exp.Expr]: - dialect = info.data.get("dialect") + dialect = validation_data(info).get("dialect") if isinstance(vs, exp.Paren): vs = vs.unnest() diff --git a/sqlmesh/core/state_sync/common.py b/sqlmesh/core/state_sync/common.py index d1208c5213..6308c0c29d 100644 --- a/sqlmesh/core/state_sync/common.py +++ b/sqlmesh/core/state_sync/common.py @@ -11,7 +11,7 @@ from pydantic_core.core_schema import ValidationInfo from sqlglot import exp -from sqlmesh.utils.pydantic import PydanticModel, field_validator +from sqlmesh.utils.pydantic import PydanticModel, field_validator, validation_data from sqlmesh.core.environment import Environment, EnvironmentStatements, EnvironmentNamingInfo from sqlmesh.core.snapshot import ( Snapshot, @@ -269,7 +269,7 @@ class PromotionResult(PydanticModel): def _validate_removed_environment_naming_info( cls, v: t.Optional[EnvironmentNamingInfo], info: ValidationInfo ) -> t.Optional[EnvironmentNamingInfo]: - if v and not info.data.get("removed"): + if v and not validation_data(info).get("removed"): raise ValueError("removed_environment_naming_info must be None if removed is empty") return v diff --git a/sqlmesh/core/user.py b/sqlmesh/core/user.py index fabc06516f..f40188a471 100644 --- a/sqlmesh/core/user.py +++ b/sqlmesh/core/user.py @@ -2,7 +2,7 @@ from enum import Enum from sqlmesh.core.notification_target import BasicSMTPNotificationTarget, NotificationTarget -from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator +from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator, validation_data class UserRole(str, Enum): @@ -42,7 +42,7 @@ def validate_notification_targets( v: t.List[NotificationTarget], info: ValidationInfo, ) -> t.List[NotificationTarget]: - email = info.data["email"] + email = validation_data(info).get("email") for target in v: if isinstance(target, BasicSMTPNotificationTarget) and target.recipients != {email}: raise ValueError("Recipient emails do not match user email") diff --git a/sqlmesh/utils/pydantic.py b/sqlmesh/utils/pydantic.py index 216fa7ddaf..43f389ef62 100644 --- a/sqlmesh/utils/pydantic.py +++ b/sqlmesh/utils/pydantic.py @@ -41,6 +41,19 @@ def field_serializer(*args: t.Any, **kwargs: t.Any) -> t.Callable[[t.Any], t.Any return pydantic.field_serializer(*args, **kwargs) +def validation_data(info_or_data: t.Any) -> t.Dict[str, t.Any]: + """Safely extract the validated-data dict from a ValidationInfo, dict, or None. + + Pydantic 2.13+ sets ValidationInfo.data to None during model_validate_json(). + This normalizes all inputs to a dict, returning an empty dict when data is unavailable. + """ + if isinstance(info_or_data, dict): + return info_or_data + if info_or_data is not None: + return info_or_data.data or {} + return {} + + def get_dialect(values: t.Any) -> str: """Extracts dialect from a dict or pydantic obj, defaulting to the globally set dialect. @@ -52,13 +65,7 @@ def get_dialect(values: t.Any) -> str: from sqlmesh.core.model import model - if isinstance(values, dict): - data = values - elif values is not None: - data = values.data - else: - data = None - dialect = data.get("dialect") if data is not None else None + dialect = validation_data(values).get("dialect") return model._dialect if dialect is None else dialect # type: ignore diff --git a/web/server/models.py b/web/server/models.py index d26848e068..c81fd8eb5a 100644 --- a/web/server/models.py +++ b/web/server/models.py @@ -19,7 +19,7 @@ SnapshotId, ) from sqlmesh.utils.date import TimeLike, now_timestamp -from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator +from sqlmesh.utils.pydantic import PydanticModel, ValidationInfo, field_validator, validation_data SUPPORTED_EXTENSIONS = {".py", ".sql", ".yaml", ".yml", ".csv"} @@ -117,8 +117,9 @@ class File(PydanticModel): @field_validator("extension", mode="before") def default_extension(cls, v: str, info: ValidationInfo) -> str: - if "name" in info.data: - return pathlib.Path(info.data["name"]).suffix + data = validation_data(info) + if "name" in data: + return pathlib.Path(data["name"]).suffix return v