From c93caef206e08582f282fc8ae09c7ca0c0c52497 Mon Sep 17 00:00:00 2001 From: Mika Allert Date: Sun, 19 Oct 2025 12:59:47 +0200 Subject: [PATCH 1/3] Adjusted import and error message behavior based on types --- pandas/compat/_optional.py | 2 +- pandas/io/sql.py | 20 ++++++++++---- pandas/tests/io/test_sql.py | 29 ++++++++++++++++++- pandas/util/_test_decorators.py | 49 +++++++++++++++++++++++++++++++-- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/pandas/compat/_optional.py b/pandas/compat/_optional.py index 6b04b4dd3a141..4e7869f4f9258 100644 --- a/pandas/compat/_optional.py +++ b/pandas/compat/_optional.py @@ -151,7 +151,7 @@ def import_optional_dependency( install_name = package_name if package_name is not None else name msg = ( - f"`Import {install_name}` failed. {extra} " + f"`Import {install_name} failed. {extra} " f"Use pip or conda to install the {install_name} package." ) try: diff --git a/pandas/io/sql.py b/pandas/io/sql.py index 18129257af1c9..19bf39e733883 100644 --- a/pandas/io/sql.py +++ b/pandas/io/sql.py @@ -895,13 +895,23 @@ def pandasSQL_builder( if isinstance(con, sqlite3.Connection) or con is None: return SQLiteDatabase(con) - sqlalchemy = import_optional_dependency("sqlalchemy", errors="ignore") + is_sqlalchemy_type = isinstance(con, str) or con.__module__.startswith("sqlalchemy") - if isinstance(con, str) and sqlalchemy is None: - raise ImportError("Using URI string without sqlalchemy installed.") + if is_sqlalchemy_type: + try: + sqlalchemy = import_optional_dependency( + "sqlalchemy", min_version="2.0.36", errors="raise" + ) + except ImportError as e: + if isinstance(con, str): + raise ImportError( + "Using URI string without matching version of sqlalchemy installed." + ) from e + else: + raise e - if sqlalchemy is not None and isinstance(con, (str, sqlalchemy.engine.Connectable)): - return SQLDatabase(con, schema, need_transaction) + if isinstance(con, (str, sqlalchemy.engine.Connectable)): + return SQLDatabase(con, schema, need_transaction) adbc = import_optional_dependency("adbc_driver_manager.dbapi", errors="ignore") if adbc and isinstance(con, adbc.Connection): diff --git a/pandas/tests/io/test_sql.py b/pandas/tests/io/test_sql.py index 1b9ae5d8e7209..87662820e9e87 100644 --- a/pandas/tests/io/test_sql.py +++ b/pandas/tests/io/test_sql.py @@ -2598,11 +2598,38 @@ def test_sql_open_close(test_frame3): @td.skip_if_installed("sqlalchemy") def test_con_string_import_error(): conn = "mysql://root@localhost/pandas" - msg = "Using URI string without sqlalchemy installed" + msg = "Using URI string without matching version of sqlalchemy installed." with pytest.raises(ImportError, match=msg): sql.read_sql("SELECT * FROM iris", conn) +@td.skip_if_installed("sqlalchemy") +@pytest.mark.parametrize("conn", sqlalchemy_connectable) +def test_con_sqlalchemy_import_error(conn, request): + conn = request.getfixturevalue(conn) + msg = r"Pandas requires version '2\.0\.36' or newer of 'sqlalchemy'.*" + with pytest.raises(ImportError, match=msg): + _ = pandasSQL_builder(conn) + + +@td.skip_if_no_unsupported_installed("sqlalchemy", min_version="2.0.36") +@pytest.mark.parametrize("conn", sqlalchemy_connectable) +def test_con_sqlalchemy_import_error_unsupported(conn, request): + conn = request.getfixturevalue(conn) + msg = r"Import SQLAlchemy failed\..*" + with pytest.raises(ImportError, match=msg): + _ = pandasSQL_builder(conn) + + +@td.skip_if_no("sqlalchemy", min_version="2.0.36") +@pytest.mark.parametrize("conn", sqlalchemy_connectable) +def test_con_sqlalchemy_connectable(conn, request): + conn = request.getfixturevalue(conn) + pandas_sql = pandasSQL_builder(conn) + + assert isinstance(pandas_sql, SQLDatabase) + + @td.skip_if_installed("sqlalchemy") def test_con_unknown_dbapi2_class_does_not_error_without_sql_alchemy_installed(): class MockSqliteConnection: diff --git a/pandas/util/_test_decorators.py b/pandas/util/_test_decorators.py index 1c17587db72d4..bf09c2ba32435 100644 --- a/pandas/util/_test_decorators.py +++ b/pandas/util/_test_decorators.py @@ -43,7 +43,9 @@ def test_foo(): from pandas.compat._optional import import_optional_dependency -def skip_if_installed(package: str) -> pytest.MarkDecorator: +def skip_if_installed( + package: str, min_version: str | None = None +) -> pytest.MarkDecorator: """ Skip a test if a package is installed. @@ -51,6 +53,8 @@ def skip_if_installed(package: str) -> pytest.MarkDecorator: ---------- package : str The name of the package. + min_version: str or None, default None + Optional minimum version of the package. Returns ------- @@ -58,9 +62,48 @@ def skip_if_installed(package: str) -> pytest.MarkDecorator: a pytest.mark.skipif to use as either a test decorator or a parametrization mark. """ + msg = f"Skipping because '{package}'" + if min_version: + msg += f" satisfying a min_version of {min_version}" + msg += " is installed." return pytest.mark.skipif( - bool(import_optional_dependency(package, errors="ignore")), - reason=f"Skipping because {package} is installed.", + bool( + import_optional_dependency( + package, min_version=min_version, errors="ignore" + ) + ), + reason=msg, + ) + + +def skip_if_no_unsupported_installed( + package: str, min_version: str +) -> pytest.MarkDecorator: + """ + Skip a test if there is no unsupported version of a package installed. + The test will hence be executed only if an unsupported version is installed. + + Parameters + ---------- + package : str + The name of the package. + min_version: str or None, default None + The minimum version of the package. + + Returns + ------- + pytest.MarkDecorator + a pytest.mark.skipif to use as either a test decorator or a + parametrization mark. + """ + return pytest.mark.skipif( + bool(import_optional_dependency(package, errors="ignore")) + and not bool( + import_optional_dependency( + package, min_version=min_version, errors="ignore" + ) + ), + reason=f"Skipping because no unsupported version of {package} is installed", ) From 17e23db0deef0808d8e63b4fcc85efb1c7b5f039 Mon Sep 17 00:00:00 2001 From: Mika Allert Date: Wed, 22 Oct 2025 18:44:46 +0200 Subject: [PATCH 2/3] Revert "Adjusted import and error message behavior based on types" This reverts commit c93caef206e08582f282fc8ae09c7ca0c0c52497. --- pandas/compat/_optional.py | 2 +- pandas/io/sql.py | 20 ++++---------- pandas/tests/io/test_sql.py | 29 +------------------ pandas/util/_test_decorators.py | 49 ++------------------------------- 4 files changed, 10 insertions(+), 90 deletions(-) diff --git a/pandas/compat/_optional.py b/pandas/compat/_optional.py index 4e7869f4f9258..6b04b4dd3a141 100644 --- a/pandas/compat/_optional.py +++ b/pandas/compat/_optional.py @@ -151,7 +151,7 @@ def import_optional_dependency( install_name = package_name if package_name is not None else name msg = ( - f"`Import {install_name} failed. {extra} " + f"`Import {install_name}` failed. {extra} " f"Use pip or conda to install the {install_name} package." ) try: diff --git a/pandas/io/sql.py b/pandas/io/sql.py index 19bf39e733883..18129257af1c9 100644 --- a/pandas/io/sql.py +++ b/pandas/io/sql.py @@ -895,23 +895,13 @@ def pandasSQL_builder( if isinstance(con, sqlite3.Connection) or con is None: return SQLiteDatabase(con) - is_sqlalchemy_type = isinstance(con, str) or con.__module__.startswith("sqlalchemy") + sqlalchemy = import_optional_dependency("sqlalchemy", errors="ignore") - if is_sqlalchemy_type: - try: - sqlalchemy = import_optional_dependency( - "sqlalchemy", min_version="2.0.36", errors="raise" - ) - except ImportError as e: - if isinstance(con, str): - raise ImportError( - "Using URI string without matching version of sqlalchemy installed." - ) from e - else: - raise e + if isinstance(con, str) and sqlalchemy is None: + raise ImportError("Using URI string without sqlalchemy installed.") - if isinstance(con, (str, sqlalchemy.engine.Connectable)): - return SQLDatabase(con, schema, need_transaction) + if sqlalchemy is not None and isinstance(con, (str, sqlalchemy.engine.Connectable)): + return SQLDatabase(con, schema, need_transaction) adbc = import_optional_dependency("adbc_driver_manager.dbapi", errors="ignore") if adbc and isinstance(con, adbc.Connection): diff --git a/pandas/tests/io/test_sql.py b/pandas/tests/io/test_sql.py index 87662820e9e87..1b9ae5d8e7209 100644 --- a/pandas/tests/io/test_sql.py +++ b/pandas/tests/io/test_sql.py @@ -2598,38 +2598,11 @@ def test_sql_open_close(test_frame3): @td.skip_if_installed("sqlalchemy") def test_con_string_import_error(): conn = "mysql://root@localhost/pandas" - msg = "Using URI string without matching version of sqlalchemy installed." + msg = "Using URI string without sqlalchemy installed" with pytest.raises(ImportError, match=msg): sql.read_sql("SELECT * FROM iris", conn) -@td.skip_if_installed("sqlalchemy") -@pytest.mark.parametrize("conn", sqlalchemy_connectable) -def test_con_sqlalchemy_import_error(conn, request): - conn = request.getfixturevalue(conn) - msg = r"Pandas requires version '2\.0\.36' or newer of 'sqlalchemy'.*" - with pytest.raises(ImportError, match=msg): - _ = pandasSQL_builder(conn) - - -@td.skip_if_no_unsupported_installed("sqlalchemy", min_version="2.0.36") -@pytest.mark.parametrize("conn", sqlalchemy_connectable) -def test_con_sqlalchemy_import_error_unsupported(conn, request): - conn = request.getfixturevalue(conn) - msg = r"Import SQLAlchemy failed\..*" - with pytest.raises(ImportError, match=msg): - _ = pandasSQL_builder(conn) - - -@td.skip_if_no("sqlalchemy", min_version="2.0.36") -@pytest.mark.parametrize("conn", sqlalchemy_connectable) -def test_con_sqlalchemy_connectable(conn, request): - conn = request.getfixturevalue(conn) - pandas_sql = pandasSQL_builder(conn) - - assert isinstance(pandas_sql, SQLDatabase) - - @td.skip_if_installed("sqlalchemy") def test_con_unknown_dbapi2_class_does_not_error_without_sql_alchemy_installed(): class MockSqliteConnection: diff --git a/pandas/util/_test_decorators.py b/pandas/util/_test_decorators.py index bf09c2ba32435..1c17587db72d4 100644 --- a/pandas/util/_test_decorators.py +++ b/pandas/util/_test_decorators.py @@ -43,9 +43,7 @@ def test_foo(): from pandas.compat._optional import import_optional_dependency -def skip_if_installed( - package: str, min_version: str | None = None -) -> pytest.MarkDecorator: +def skip_if_installed(package: str) -> pytest.MarkDecorator: """ Skip a test if a package is installed. @@ -53,8 +51,6 @@ def skip_if_installed( ---------- package : str The name of the package. - min_version: str or None, default None - Optional minimum version of the package. Returns ------- @@ -62,48 +58,9 @@ def skip_if_installed( a pytest.mark.skipif to use as either a test decorator or a parametrization mark. """ - msg = f"Skipping because '{package}'" - if min_version: - msg += f" satisfying a min_version of {min_version}" - msg += " is installed." return pytest.mark.skipif( - bool( - import_optional_dependency( - package, min_version=min_version, errors="ignore" - ) - ), - reason=msg, - ) - - -def skip_if_no_unsupported_installed( - package: str, min_version: str -) -> pytest.MarkDecorator: - """ - Skip a test if there is no unsupported version of a package installed. - The test will hence be executed only if an unsupported version is installed. - - Parameters - ---------- - package : str - The name of the package. - min_version: str or None, default None - The minimum version of the package. - - Returns - ------- - pytest.MarkDecorator - a pytest.mark.skipif to use as either a test decorator or a - parametrization mark. - """ - return pytest.mark.skipif( - bool(import_optional_dependency(package, errors="ignore")) - and not bool( - import_optional_dependency( - package, min_version=min_version, errors="ignore" - ) - ), - reason=f"Skipping because no unsupported version of {package} is installed", + bool(import_optional_dependency(package, errors="ignore")), + reason=f"Skipping because {package} is installed.", ) From 36b30600b983d9e891380aee33baccff54ee8fb2 Mon Sep 17 00:00:00 2001 From: Mika Allert Date: Wed, 22 Oct 2025 18:50:28 +0200 Subject: [PATCH 3/3] Improve sqlalchemy Error Message --- pandas/io/sql.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pandas/io/sql.py b/pandas/io/sql.py index 18129257af1c9..eceea87316535 100644 --- a/pandas/io/sql.py +++ b/pandas/io/sql.py @@ -35,7 +35,10 @@ from pandas._config import using_string_dtype from pandas._libs import lib -from pandas.compat._optional import import_optional_dependency +from pandas.compat._optional import ( + VERSIONS, + import_optional_dependency, +) from pandas.errors import ( AbstractMethodError, DatabaseError, @@ -898,7 +901,10 @@ def pandasSQL_builder( sqlalchemy = import_optional_dependency("sqlalchemy", errors="ignore") if isinstance(con, str) and sqlalchemy is None: - raise ImportError("Using URI string without sqlalchemy installed.") + raise ImportError( + f"Using URI string without version '{VERSIONS['sqlalchemy']}' or newer " + "of 'sqlalchemy' installed." + ) if sqlalchemy is not None and isinstance(con, (str, sqlalchemy.engine.Connectable)): return SQLDatabase(con, schema, need_transaction)