diff --git a/sagemaker-core/pyproject.toml b/sagemaker-core/pyproject.toml index 2756ce0f1c..c53334fc25 100644 --- a/sagemaker-core/pyproject.toml +++ b/sagemaker-core/pyproject.toml @@ -13,7 +13,7 @@ readme = "README.rst" dependencies = [ # Add your dependencies here (Include lower and upper bounds as applicable) "boto3>=1.42.2,<2.0.0", - "pydantic>=2.0.0,<3.0.0", + "pydantic>=2.10.0,<3.0.0", "PyYAML>=6.0, <7.0", "jsonschema<5.0.0", "platformdirs>=4.0.0, <5.0.0", diff --git a/sagemaker-core/src/sagemaker/core/__init__.py b/sagemaker-core/src/sagemaker/core/__init__.py index f25f18009d..4463905a5d 100644 --- a/sagemaker-core/src/sagemaker/core/__init__.py +++ b/sagemaker-core/src/sagemaker/core/__init__.py @@ -1,3 +1,14 @@ +# Early pydantic compatibility check - must happen before any pydantic imports +try: + from sagemaker.core._pydantic_compat import check_pydantic_compatibility + check_pydantic_compatibility() +except ImportError as e: + if "pydantic" in str(e).lower(): + raise + # If it's a different ImportError (e.g., module not found for _pydantic_compat itself), + # let it pass and fail later with a more standard error + pass + from sagemaker.core.utils.utils import enable_textual_rich_console_and_traceback diff --git a/sagemaker-core/src/sagemaker/core/_pydantic_compat.py b/sagemaker-core/src/sagemaker/core/_pydantic_compat.py new file mode 100644 index 0000000000..bf3644da6f --- /dev/null +++ b/sagemaker-core/src/sagemaker/core/_pydantic_compat.py @@ -0,0 +1,51 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +"""Pydantic compatibility check for sagemaker-core. + +This module provides an early check for pydantic/pydantic-core version +compatibility to give users a clear error message with fix instructions +instead of a cryptic SystemError. + +When pydantic-core is upgraded independently of pydantic (e.g., via +``pip install --force-reinstall --no-deps``), Python raises a ``SystemError`` +at import time. This module catches that error and converts it into a +helpful ``ImportError`` with remediation steps. +""" + + +def check_pydantic_compatibility(): + """Check that pydantic and pydantic-core versions are compatible. + + Pydantic internally requires an exact matching pydantic-core version + (e.g., pydantic 2.11.5 requires pydantic-core==2.41.5). If the two + packages are out of sync, ``import pydantic`` raises a ``SystemError``. + + This function catches that ``SystemError`` and re-raises it as an + ``ImportError`` with clear instructions on how to fix the issue. + + Raises: + ImportError: If pydantic and pydantic-core versions are incompatible, + with instructions on how to fix the issue. + """ + try: + import pydantic # noqa: F401 + except SystemError as e: + error_message = str(e) + raise ImportError( + f"Pydantic version incompatibility detected: {error_message}\n\n" + "This typically happens when pydantic-core is upgraded independently " + "of pydantic, causing a version mismatch.\n\n" + "To fix this, run:\n" + " pip install pydantic pydantic-core --force-reinstall\n\n" + "This will ensure both packages are installed at compatible versions." + ) from e diff --git a/sagemaker-core/tests/unit/test_pydantic_compat.py b/sagemaker-core/tests/unit/test_pydantic_compat.py new file mode 100644 index 0000000000..0aa6d35e2c --- /dev/null +++ b/sagemaker-core/tests/unit/test_pydantic_compat.py @@ -0,0 +1,99 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +"""Tests for pydantic compatibility check.""" + +import builtins +import sys +from unittest import mock + +import pytest + + +def test_check_pydantic_compatibility_passes_with_matching_versions(): + """Verify the check function does not raise when pydantic and pydantic-core are compatible.""" + from sagemaker.core._pydantic_compat import check_pydantic_compatibility + + # Should not raise any exception with the currently installed versions + check_pydantic_compatibility() + + +def _make_pydantic_system_error_import(error_msg): + """Create a mock import function that raises SystemError for pydantic.""" + _real_import = builtins.__import__ + + def _mock_import(name, *args, **kwargs): + if name == "pydantic": + raise SystemError(error_msg) + return _real_import(name, *args, **kwargs) + + return _mock_import + + +def test_check_pydantic_compatibility_raises_on_system_error(): + """Mock pydantic import to raise SystemError and verify a clear ImportError is raised.""" + from sagemaker.core._pydantic_compat import check_pydantic_compatibility + + error_msg = ( + "The installed pydantic-core version (2.42.0) is incompatible " + "with the current pydantic version, which requires 2.41.5." + ) + + mock_import = _make_pydantic_system_error_import(error_msg) + + with mock.patch.dict(sys.modules, {"pydantic": None}): + with mock.patch("builtins.__import__", side_effect=mock_import): + with pytest.raises(ImportError) as exc_info: + check_pydantic_compatibility() + + error_str = str(exc_info.value).lower() + assert "incompatibility detected" in error_str + + +def test_pydantic_import_error_message_contains_instructions(): + """Verify the error message includes pip install instructions.""" + from sagemaker.core._pydantic_compat import check_pydantic_compatibility + + error_msg = ( + "The installed pydantic-core version (2.42.0) is incompatible " + "with the current pydantic version, which requires 2.41.5." + ) + + mock_import = _make_pydantic_system_error_import(error_msg) + + with mock.patch.dict(sys.modules, {"pydantic": None}): + with mock.patch("builtins.__import__", side_effect=mock_import): + with pytest.raises(ImportError) as exc_info: + check_pydantic_compatibility() + + error_str = str(exc_info.value) + assert "pip install pydantic pydantic-core --force-reinstall" in error_str + + +def test_pydantic_import_error_chains_original_system_error(): + """Verify the ImportError chains the original SystemError as __cause__.""" + from sagemaker.core._pydantic_compat import check_pydantic_compatibility + + error_msg = ( + "The installed pydantic-core version (2.42.0) is incompatible " + "with the current pydantic version, which requires 2.41.5." + ) + + mock_import = _make_pydantic_system_error_import(error_msg) + + with mock.patch.dict(sys.modules, {"pydantic": None}): + with mock.patch("builtins.__import__", side_effect=mock_import): + with pytest.raises(ImportError) as exc_info: + check_pydantic_compatibility() + + assert isinstance(exc_info.value.__cause__, SystemError) + assert "incompatible" in str(exc_info.value.__cause__).lower()