From 9a4b82e798f9658e9edfac7f9fbd07e530b9993f Mon Sep 17 00:00:00 2001 From: Mike Bayer Date: Thu, 6 Aug 2026 12:37:46 -0400 Subject: [PATCH] Standardize DBAPI version access across dialects Added :attr:`.Dialect.dbapi_version`, a standardized accessor for the version of the DBAPI module in use by a dialect, in contrast to :attr:`.Dialect.server_version_info` which refers to the database server. The implementation on :class:`.DefaultDialect` makes use of a new per-dialect method :meth:`.Dialect.retrieve_dbapi_version` in order to retrieve the version from the DBAPI module and return it as a :class:`.VersionInfo` object, which is a tuple subclass with additional properties; third-party dialects should also implement the :meth:`.Dialect.retrieve_dbapi_version` method. Fixed issue in the asyncpg dialect where the version of the ``asyncpg`` DBAPI would always be reported as ``(99, 99, 99)``, as the version was looked up on the dialect's DBAPI wrapper module rather than on the ``asyncpg`` module itself. Fixed issue where the version of the DBAPI reported by the mysqldb and pymysql dialects was incorrect. Current mysqlclient releases publish ``MySQLdb.version_info`` and no version string at all, so no version was reported; pymysql publishes ``__version__`` and ``version_info`` as mysqlclient compatibility values, so the version reported for pymysql was that of the mysqlclient release it emulates, e.g. ``(2, 2, 8)`` rather than ``(1, 2, 0)``. The version specifications used by testing exclusions such as ``testing.fails_if("+asyncmy<0.2.13")`` now support a driver name, in which case the comparison is against the version of the DBAPI rather than that of the database server. Previously this form raised ``AssertionError: DBAPI version specs not supported yet``. Change-Id: I09035a861adc6892f84a6c3a788e6b4657858820 --- .../changelog/unreleased_21/dbapi_version.rst | 40 +++ lib/sqlalchemy/connectors/pyodbc.py | 21 +- lib/sqlalchemy/dialects/mssql/mssqlpython.py | 19 +- lib/sqlalchemy/dialects/mssql/pymssql.py | 5 +- lib/sqlalchemy/dialects/mssql/pyodbc.py | 8 +- lib/sqlalchemy/dialects/mysql/aiomysql.py | 9 + lib/sqlalchemy/dialects/mysql/asyncmy.py | 5 + .../dialects/mysql/mariadbconnector.py | 27 +- .../dialects/mysql/mysqlconnector.py | 10 +- lib/sqlalchemy/dialects/mysql/mysqldb.py | 35 ++- lib/sqlalchemy/dialects/mysql/pymysql.py | 10 + lib/sqlalchemy/dialects/oracle/cx_oracle.py | 29 +- lib/sqlalchemy/dialects/oracle/oracledb.py | 36 +-- .../dialects/postgresql/_psycopg_common.py | 3 + lib/sqlalchemy/dialects/postgresql/asyncpg.py | 18 +- lib/sqlalchemy/dialects/postgresql/pg8000.py | 21 +- lib/sqlalchemy/dialects/postgresql/psycopg.py | 26 +- .../dialects/postgresql/psycopg2.py | 23 +- lib/sqlalchemy/dialects/sqlite/aiosqlite.py | 9 + lib/sqlalchemy/dialects/sqlite/pysqlcipher.py | 6 + lib/sqlalchemy/dialects/sqlite/pysqlite.py | 16 +- lib/sqlalchemy/engine/default.py | 78 +++++- lib/sqlalchemy/engine/interfaces.py | 121 +++++++- lib/sqlalchemy/exc.py | 14 + lib/sqlalchemy/testing/exclusions.py | 31 ++- lib/sqlalchemy/util/__init__.py | 5 + lib/sqlalchemy/util/langhelpers.py | 263 ++++++++++++++++++ test/base/test_except.py | 1 + test/base/test_utils.py | 166 +++++++++++ test/dialect/mysql/test_dialect.py | 47 ++++ test/dialect/oracle/test_dialect.py | 39 ++- test/dialect/test_pyodbc.py | 15 - test/engine/test_parseconnect.py | 167 +++++++++++ test/requirements.py | 2 +- 34 files changed, 1116 insertions(+), 209 deletions(-) create mode 100644 doc/build/changelog/unreleased_21/dbapi_version.rst delete mode 100644 test/dialect/test_pyodbc.py diff --git a/doc/build/changelog/unreleased_21/dbapi_version.rst b/doc/build/changelog/unreleased_21/dbapi_version.rst new file mode 100644 index 0000000000..c76fd4c44d --- /dev/null +++ b/doc/build/changelog/unreleased_21/dbapi_version.rst @@ -0,0 +1,40 @@ +.. change:: + :tags: feature, engine + + Added :attr:`.Dialect.dbapi_version`, a standardized accessor for the + version of the DBAPI module in use by a dialect, in contrast to + :attr:`.Dialect.server_version_info` which refers to the database server. + The implementation on :class:`.DefaultDialect` makes use of a new + per-dialect method :meth:`.Dialect.retrieve_dbapi_version` in order to + retrieve the version from the DBAPI module and return it as a + :class:`.VersionInfo` object, which is a tuple subclass with additional + properties; third-party dialects should also implement the + :meth:`.Dialect.retrieve_dbapi_version` method. + +.. change:: + :tags: bug, postgresql + + Fixed issue in the asyncpg dialect where the version of the ``asyncpg`` + DBAPI would always be reported as ``(99, 99, 99)``, as the version was + looked up on the dialect's DBAPI wrapper module rather than on the + ``asyncpg`` module itself. + +.. change:: + :tags: bug, mysql + + Fixed issue where the version of the DBAPI reported by the mysqldb and + pymysql dialects was incorrect. Current mysqlclient releases publish + ``MySQLdb.version_info`` and no version string at all, so no version was + reported; pymysql publishes ``__version__`` and ``version_info`` as + mysqlclient compatibility values, so the version reported for pymysql + was that of the mysqlclient release it emulates, e.g. ``(2, 2, 8)`` + rather than ``(1, 2, 0)``. + +.. change:: + :tags: bug, testing + + The version specifications used by testing exclusions such as + ``testing.fails_if("+asyncmy<0.2.13")`` now support a driver name, in + which case the comparison is against the version of the DBAPI rather + than that of the database server. Previously this form raised + ``AssertionError: DBAPI version specs not supported yet``. diff --git a/lib/sqlalchemy/connectors/pyodbc.py b/lib/sqlalchemy/connectors/pyodbc.py index 84a3f04311..d41b981165 100644 --- a/lib/sqlalchemy/connectors/pyodbc.py +++ b/lib/sqlalchemy/connectors/pyodbc.py @@ -164,25 +164,14 @@ class PyODBCConnector(Connector): else: return False - def _dbapi_version(self) -> interfaces.VersionInfoType: - if not self.dbapi: - return () - return self._parse_dbapi_version(self.dbapi.version) - - def _parse_dbapi_version(self, vers: str) -> interfaces.VersionInfoType: - m = re.match(r"(?:py.*-)?([\d\.]+)(?:-(\w+))?", vers) - if not m: - return () - vers_tuple: interfaces.VersionInfoType = tuple( - [int(x) for x in m.group(1).split(".")] - ) - if m.group(2): - vers_tuple += (m.group(2),) - return vers_tuple + def retrieve_dbapi_version( + self, dbapi: interfaces.DBAPIModule + ) -> util.VersionInfo: + return util.parse_version_string(dbapi.version) def _get_server_version_info( self, connection: Connection - ) -> interfaces.VersionInfoType: + ) -> interfaces.ServerVersionInfoType: # NOTE: this function is not reliable, particularly when # freetds is in use. Implement database-specific server version # queries. diff --git a/lib/sqlalchemy/dialects/mssql/mssqlpython.py b/lib/sqlalchemy/dialects/mssql/mssqlpython.py index 9c388a2af3..ff4dd3589f 100644 --- a/lib/sqlalchemy/dialects/mssql/mssqlpython.py +++ b/lib/sqlalchemy/dialects/mssql/mssqlpython.py @@ -174,21 +174,10 @@ class MSDialect_mssqlpython(MSDialect): else: return False - def _dbapi_version(self) -> interfaces.VersionInfoType: - if not self.dbapi: - return () - return self._parse_dbapi_version(self.dbapi.version) - - def _parse_dbapi_version(self, vers: str) -> interfaces.VersionInfoType: - m = re.match(r"(?:py.*-)?([\d\.]+)(?:-(\w+))?", vers) - if not m: - return () - vers_tuple: interfaces.VersionInfoType = tuple( - [int(x) for x in m.group(1).split(".")] - ) - if m.group(2): - vers_tuple += (m.group(2),) - return vers_tuple + def retrieve_dbapi_version( + self, dbapi: interfaces.DBAPIModule + ) -> util.VersionInfo: + return util.parse_version_string(dbapi.version) def _get_server_version_info(self, connection): vers = connection.exec_driver_sql("select @@version").scalar() diff --git a/lib/sqlalchemy/dialects/mssql/pymssql.py b/lib/sqlalchemy/dialects/mssql/pymssql.py index 6c6c3a2e74..7347b95815 100644 --- a/lib/sqlalchemy/dialects/mssql/pymssql.py +++ b/lib/sqlalchemy/dialects/mssql/pymssql.py @@ -61,11 +61,14 @@ class MSDialect_pymssql(MSDialect): {sqltypes.Numeric: _MSNumeric_pymssql, sqltypes.Float: sqltypes.Float}, ) + def retrieve_dbapi_version(self, dbapi): + return util.parse_version_string(getattr(dbapi, "__version__", None)) + @classmethod def import_dbapi(cls): module = __import__("pymssql") # pymmsql < 2.1.1 doesn't have a Binary method. we use string - client_ver = tuple(int(x) for x in module.__version__.split(".")) + client_ver = util.parse_version_string(module.__version__) if client_ver < (2, 1, 1): # TODO: monkeypatching here is less than ideal module.Binary = lambda x: x if hasattr(x, "decode") else str(x) diff --git a/lib/sqlalchemy/dialects/mssql/pyodbc.py b/lib/sqlalchemy/dialects/mssql/pyodbc.py index f1bfbb5679..ff37fc2a47 100644 --- a/lib/sqlalchemy/dialects/mssql/pyodbc.py +++ b/lib/sqlalchemy/dialects/mssql/pyodbc.py @@ -606,10 +606,10 @@ class MSDialect_pyodbc(PyODBCConnector, MSDialect): and self.dbapi and hasattr(self.dbapi.Cursor, "nextset") ) - self._need_decimal_fix = self.dbapi and self._dbapi_version() < ( - 2, - 1, - 8, + # a version which can't be determined is assumed to be an old one + version = self._dbapi_version_or_none + self._need_decimal_fix = bool(self.dbapi) and ( + version is None or version < (2, 1, 8) ) self.fast_executemany = fast_executemany if fast_executemany: diff --git a/lib/sqlalchemy/dialects/mysql/aiomysql.py b/lib/sqlalchemy/dialects/mysql/aiomysql.py index 67fca8ee2f..099a7de18c 100644 --- a/lib/sqlalchemy/dialects/mysql/aiomysql.py +++ b/lib/sqlalchemy/dialects/mysql/aiomysql.py @@ -39,6 +39,7 @@ from typing import Union from .pymysql import _connection_ping_reconnects_true from .pymysql import MySQLDialect_pymysql +from ... import util from ...connectors.asyncio import AsyncAdapt_dbapi_connection from ...connectors.asyncio import AsyncAdapt_dbapi_cursor from ...connectors.asyncio import AsyncAdapt_dbapi_module @@ -215,6 +216,14 @@ class MySQLDialect_aiomysql(MySQLDialect_pymysql): __import__("aiomysql"), __import__("pymysql") ) + def retrieve_dbapi_version(self, dbapi: DBAPIModule) -> util.VersionInfo: + # the version of aiomysql itself, rather than that of the pymysql + # module it makes use of + aiomysql = getattr(dbapi, "aiomysql", None) + return util.parse_version_string( + getattr(aiomysql, "__version__", None) + ) + def do_terminate(self, dbapi_connection: DBAPIConnection) -> None: dbapi_connection.terminate() diff --git a/lib/sqlalchemy/dialects/mysql/asyncmy.py b/lib/sqlalchemy/dialects/mysql/asyncmy.py index c2b7310273..7544d225c0 100644 --- a/lib/sqlalchemy/dialects/mysql/asyncmy.py +++ b/lib/sqlalchemy/dialects/mysql/asyncmy.py @@ -200,6 +200,11 @@ class MySQLDialect_asyncmy(MySQLDialect_pymysql): def import_dbapi(cls) -> DBAPIModule: return AsyncAdapt_asyncmy_dbapi(__import__("asyncmy")) + def retrieve_dbapi_version(self, dbapi: DBAPIModule) -> util.VersionInfo: + # asyncmy publishes no version of its own within the module, so + # the installed distribution is consulted + return util.parse_version_from_metadata("asyncmy") + def do_terminate(self, dbapi_connection: DBAPIConnection) -> None: dbapi_connection.terminate() diff --git a/lib/sqlalchemy/dialects/mysql/mariadbconnector.py b/lib/sqlalchemy/dialects/mysql/mariadbconnector.py index 2c17b7b5f9..0d4fd73110 100644 --- a/lib/sqlalchemy/dialects/mysql/mariadbconnector.py +++ b/lib/sqlalchemy/dialects/mysql/mariadbconnector.py @@ -30,7 +30,6 @@ be ``mysqldb``. ``mariadb+mariadbconnector://`` is required to use this driver. from __future__ import annotations -import re from typing import Any from typing import Optional from typing import Sequence @@ -60,7 +59,7 @@ if TYPE_CHECKING: from ...sql.type_api import _ResultProcessorType -mariadb_cpy_minimum_version = (1, 0, 1) +mariadb_cpy_minimum_version = util.VersionInfo((1, 0, 1)) class _MariaDBUUID(sqltypes.UUID[sqltypes._UUID_RETURN]): @@ -125,6 +124,8 @@ class MySQLDialect_mariadbconnector(MySQLDialect): driver = "mariadbconnector" supports_statement_cache = True + minimum_dbapi_version = mariadb_cpy_minimum_version + # set this to True at the module level to prevent the driver from running # against a backend that server detects as MySQL. currently this appears to # be unnecessary as MariaDB client libraries have always worked against @@ -151,30 +152,12 @@ class MySQLDialect_mariadbconnector(MySQLDialect): MySQLDialect.colspecs, {sqltypes.Uuid: _MariaDBUUID} ) - @util.memoized_property - def _dbapi_version(self) -> tuple[int, ...]: - if self.dbapi and hasattr(self.dbapi, "__version__"): - return tuple( - [ - int(x) - for x in re.findall( - r"(\d+)(?:[-\.]?|$)", self.dbapi.__version__ - ) - ] - ) - else: - return (99, 99, 99) + def retrieve_dbapi_version(self, dbapi: DBAPIModule) -> util.VersionInfo: + return util.parse_version_string(getattr(dbapi, "__version__", None)) def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self.paramstyle = "qmark" - if self.dbapi is not None: - if self._dbapi_version < mariadb_cpy_minimum_version: - raise NotImplementedError( - "The minimum required version for MariaDB " - "Connector/Python is %s" - % ".".join(str(x) for x in mariadb_cpy_minimum_version) - ) @classmethod def import_dbapi(cls) -> DBAPIModule: diff --git a/lib/sqlalchemy/dialects/mysql/mysqlconnector.py b/lib/sqlalchemy/dialects/mysql/mysqlconnector.py index 07a657137d..4c62aa49ff 100644 --- a/lib/sqlalchemy/dialects/mysql/mysqlconnector.py +++ b/lib/sqlalchemy/dialects/mysql/mysqlconnector.py @@ -48,7 +48,6 @@ charset/collation will allow connectivity. from __future__ import annotations -import re from typing import Any from typing import cast from typing import Optional @@ -212,13 +211,8 @@ class MySQLDialect_mysqlconnector(MySQLDialect): return [], opts - @util.memoized_property - def _mysqlconnector_version_info(self) -> Optional[tuple[int, ...]]: - if self.dbapi and hasattr(self.dbapi, "__version__"): - m = re.match(r"(\d+)\.(\d+)(?:\.(\d+))?", self.dbapi.__version__) - if m: - return tuple(int(x) for x in m.group(1, 2, 3) if x is not None) - return None + def retrieve_dbapi_version(self, dbapi: DBAPIModule) -> util.VersionInfo: + return util.parse_version_string(getattr(dbapi, "__version__", None)) def _detect_charset(self, connection: Connection) -> str: return connection.connection.charset # type: ignore[no-any-return] diff --git a/lib/sqlalchemy/dialects/mysql/mysqldb.py b/lib/sqlalchemy/dialects/mysql/mysqldb.py index e5dbf2d07a..3e896f1973 100644 --- a/lib/sqlalchemy/dialects/mysql/mysqldb.py +++ b/lib/sqlalchemy/dialects/mysql/mysqldb.py @@ -87,7 +87,7 @@ The mysqldb dialect supports server-side cursors. See :ref:`mysql_ss_cursors`. from __future__ import annotations -import re +import itertools from typing import Any from typing import Callable from typing import cast @@ -137,20 +137,25 @@ class MySQLDialect_mysqldb(MySQLDialect): preparer = MySQLIdentifierPreparer server_version_info: tuple[int, ...] - def __init__(self, **kwargs: Any): - super().__init__(**kwargs) - self._mysql_dbapi_version = ( - self._parse_dbapi_version(self.dbapi.__version__) - if self.dbapi is not None and hasattr(self.dbapi, "__version__") - else (0, 0, 0) - ) - - def _parse_dbapi_version(self, version: str) -> tuple[int, ...]: - m = re.match(r"(\d+)\.(\d+)(?:\.(\d+))?", version) - if m: - return tuple(int(x) for x in m.group(1, 2, 3) if x is not None) - else: - return (0, 0, 0) + def retrieve_dbapi_version(self, dbapi: DBAPIModule) -> util.VersionInfo: + # mysqlclient publishes ``version_info``, a tuple in the style of + # ``sys.version_info`` such as ``(2, 2, 7, "final", 0)``, and no + # version string of its own; MySQL-python published + # ``__version__``. cymysql, which subclasses this dialect, also + # publishes ``__version__``. pymysql publishes both of these as + # mysqlclient compatibility values rather than as its own version, + # so that dialect overrides this method; the asyncio dialects + # likewise override as their DBAPI is a wrapper module. + version_info = getattr(dbapi, "version_info", None) + if version_info is not None: + return util.VersionInfo( + tuple( + itertools.takewhile( + lambda part: isinstance(part, int), version_info + ) + ) + ) + return util.parse_version_string(getattr(dbapi, "__version__", None)) @util.langhelpers.memoized_property def supports_server_side_cursors(self) -> bool: diff --git a/lib/sqlalchemy/dialects/mysql/pymysql.py b/lib/sqlalchemy/dialects/mysql/pymysql.py index ad44871ba7..6e41352370 100644 --- a/lib/sqlalchemy/dialects/mysql/pymysql.py +++ b/lib/sqlalchemy/dialects/mysql/pymysql.py @@ -58,6 +58,7 @@ from typing import TYPE_CHECKING from typing import Union from .mysqldb import MySQLDialect_mysqldb +from ... import util from ...util import langhelpers if TYPE_CHECKING: @@ -101,6 +102,15 @@ class MySQLDialect_pymysql(MySQLDialect_mysqldb): description_encoding = None + def retrieve_dbapi_version(self, dbapi: DBAPIModule) -> util.VersionInfo: + # pymysql publishes its own version as ``VERSION_STRING``; the + # ``__version__`` and ``version_info`` attributes it also publishes + # are mysqlclient compatibility values, e.g. ``"2.2.8"`` for a + # pymysql that is itself version 1.2.0 + return util.parse_version_string( + getattr(dbapi, "VERSION_STRING", None) + ) + @langhelpers.memoized_property def supports_server_side_cursors(self) -> bool: try: diff --git a/lib/sqlalchemy/dialects/oracle/cx_oracle.py b/lib/sqlalchemy/dialects/oracle/cx_oracle.py index e48da44fad..aeda59ef6a 100644 --- a/lib/sqlalchemy/dialects/oracle/cx_oracle.py +++ b/lib/sqlalchemy/dialects/oracle/cx_oracle.py @@ -1221,6 +1221,8 @@ class OracleDialect_cx_oracle(OracleDialect): bind_typing = interfaces.BindTyping.SETINPUTSIZES + minimum_dbapi_version = util.VersionInfo((8,)) + driver = "cx_oracle" colspecs = util.update_copy( @@ -1282,7 +1284,6 @@ class OracleDialect_cx_oracle(OracleDialect): self.colspecs[sqltypes.UnicodeText] = _OracleUnicodeTextNCLOB dbapi_module = self.dbapi - self._load_version(dbapi_module) if dbapi_module is not None: # these constants will first be seen in SQLAlchemy datatypes @@ -1312,19 +1313,19 @@ class OracleDialect_cx_oracle(OracleDialect): self._paramval = lambda value: value.getvalue() - def _load_version(self, dbapi_module): - version = (0, 0, 0) - if dbapi_module is not None: - m = re.match(r"(\d+)\.(\d+)(?:\.(\d+))?", dbapi_module.version) - if m: - version = tuple( - int(x) for x in m.group(1, 2, 3) if x is not None - ) - self.cx_oracle_ver = version - if self.cx_oracle_ver < (8,) and self.cx_oracle_ver > (0, 0, 0): - raise exc.InvalidRequestError( - "cx_Oracle version 8 and above are supported" - ) + def retrieve_dbapi_version(self, dbapi): + return util.parse_version_string(getattr(dbapi, "version", None)) + + @property + def cx_oracle_ver(self): + """Legacy accessor for :attr:`.Dialect.dbapi_version`. + + Retained for backwards compatibility; ``(0, 0, 0)`` is returned + when no version can be determined. + + """ + version = self._dbapi_version_or_none + return version if version is not None else util.VersionInfo((0, 0, 0)) @classmethod def import_dbapi(cls): diff --git a/lib/sqlalchemy/dialects/oracle/oracledb.py b/lib/sqlalchemy/dialects/oracle/oracledb.py index cf15976906..52e2abaf4a 100644 --- a/lib/sqlalchemy/dialects/oracle/oracledb.py +++ b/lib/sqlalchemy/dialects/oracle/oracledb.py @@ -591,12 +591,11 @@ behavioral changes particularly when using the native JSON datatype. See from __future__ import annotations import collections -import re from typing import Any from typing import TYPE_CHECKING from . import cx_oracle as _cx_oracle -from ... import exc +from ... import util from ...connectors.asyncio import AsyncAdapt_dbapi_connection from ...connectors.asyncio import AsyncAdapt_dbapi_cursor from ...connectors.asyncio import AsyncAdapt_dbapi_module @@ -620,7 +619,8 @@ class OracleDialect_oracledb(_cx_oracle.OracleDialect_cx_oracle): execution_ctx_cls = OracleExecutionContext_oracledb driver = "oracledb" - _min_version = (1,) + + minimum_dbapi_version = util.VersionInfo((1,)) def __init__( self, @@ -659,22 +659,16 @@ class OracleDialect_oracledb(_cx_oracle.OracleDialect_cx_oracle): def get_async_dialect_cls(cls, url): return OracleDialectAsync_oracledb - def _load_version(self, dbapi_module): - version = (0, 0, 0) - if dbapi_module is not None: - m = re.match(r"(\d+)\.(\d+)(?:\.(\d+))?", dbapi_module.version) - if m: - version = tuple( - int(x) for x in m.group(1, 2, 3) if x is not None - ) - self.oracledb_ver = version - if ( - self.oracledb_ver > (0, 0, 0) - and self.oracledb_ver < self._min_version - ): - raise exc.InvalidRequestError( - f"oracledb version {self._min_version} and above are supported" - ) + @property + def oracledb_ver(self): + """Legacy accessor for :attr:`.Dialect.dbapi_version`. + + Retained for backwards compatibility; ``(0, 0, 0)`` is returned + when no version can be determined. + + """ + version = self._dbapi_version_or_none + return version if version is not None else util.VersionInfo((0, 0, 0)) def do_begin_twophase(self, connection, xid): conn_xis = connection.connection.xid(*xid) @@ -720,7 +714,7 @@ class OracleDialect_oracledb(_cx_oracle.OracleDialect_cx_oracle): ] def _check_max_identifier_length(self, connection): - if self.oracledb_ver >= (2, 5): + if self.dbapi_version >= (2, 5): max_len = connection.connection.max_identifier_length if max_len is not None: return max_len @@ -887,7 +881,7 @@ class OracleDialectAsync_oracledb(OracleDialect_oracledb): supports_statement_cache = True execution_ctx_cls = OracleExecutionContextAsync_oracledb - _min_version = (2, 0, 1) + minimum_dbapi_version = util.VersionInfo((2, 0, 1)) # thick_mode mode is not supported by asyncio, oracledb will raise @classmethod diff --git a/lib/sqlalchemy/dialects/postgresql/_psycopg_common.py b/lib/sqlalchemy/dialects/postgresql/_psycopg_common.py index 3a3f823cca..f2ea4d262c 100644 --- a/lib/sqlalchemy/dialects/postgresql/_psycopg_common.py +++ b/lib/sqlalchemy/dialects/postgresql/_psycopg_common.py @@ -121,6 +121,9 @@ class _PGDialect_common_psycopg(PGDialect): }, ) + def retrieve_dbapi_version(self, dbapi): + return util.parse_version_string(getattr(dbapi, "__version__", None)) + def __init__( self, client_encoding=None, diff --git a/lib/sqlalchemy/dialects/postgresql/asyncpg.py b/lib/sqlalchemy/dialects/postgresql/asyncpg.py index 4d3a3ba045..24b04a8ed7 100644 --- a/lib/sqlalchemy/dialects/postgresql/asyncpg.py +++ b/lib/sqlalchemy/dialects/postgresql/asyncpg.py @@ -1127,19 +1127,11 @@ class PGDialect_asyncpg(PGDialect): def _invalidate_schema_cache(self): self._invalidate_schema_cache_asof = time.time() - @util.memoized_property - def _dbapi_version(self): - if self.dbapi and hasattr(self.dbapi, "__version__"): - return tuple( - [ - int(x) - for x in re.findall( - r"(\d+)(?:[-\.]?|$)", self.dbapi.__version__ - ) - ] - ) - else: - return (99, 99, 99) + def retrieve_dbapi_version(self, dbapi): + # dbapi is the AsyncAdapt_asyncpg_dbapi wrapper; the version is on + # the asyncpg module itself, which is ``.driver`` + driver = getattr(dbapi, "driver", None) + return util.parse_version_string(getattr(driver, "__version__", None)) @classmethod def import_dbapi(cls): diff --git a/lib/sqlalchemy/dialects/postgresql/pg8000.py b/lib/sqlalchemy/dialects/postgresql/pg8000.py index fc1e65e66d..d107e4f1bf 100644 --- a/lib/sqlalchemy/dialects/postgresql/pg8000.py +++ b/lib/sqlalchemy/dialects/postgresql/pg8000.py @@ -97,7 +97,6 @@ of the :ref:`psycopg2 ` dialect: """ # noqa import decimal -import re from . import ranges from .array import ARRAY as PGARRAY @@ -405,6 +404,8 @@ class PGDialect_pg8000(PGDialect): driver = "pg8000" supports_statement_cache = True + minimum_dbapi_version = util.VersionInfo((1, 16, 6)) + supports_unicode_statements = True supports_unicode_binds = True @@ -473,9 +474,6 @@ class PGDialect_pg8000(PGDialect): PGDialect.__init__(self, **kwargs) self.client_encoding = client_encoding - if self._dbapi_version < (1, 16, 6): - raise NotImplementedError("pg8000 1.16.6 or greater is required") - if self._native_inet_types: raise NotImplementedError( "The pg8000 dialect does not fully implement " @@ -483,19 +481,8 @@ class PGDialect_pg8000(PGDialect): "CIDR is not" ) - @util.memoized_property - def _dbapi_version(self): - if self.dbapi and hasattr(self.dbapi, "__version__"): - return tuple( - [ - int(x) - for x in re.findall( - r"(\d+)(?:[-\.]?|$)", self.dbapi.__version__ - ) - ] - ) - else: - return (99, 99, 99) + def retrieve_dbapi_version(self, dbapi): + return util.parse_version_string(getattr(dbapi, "__version__", None)) @classmethod def import_dbapi(cls): diff --git a/lib/sqlalchemy/dialects/postgresql/psycopg.py b/lib/sqlalchemy/dialects/postgresql/psycopg.py index 46d7bbf5b5..fd147e0226 100644 --- a/lib/sqlalchemy/dialects/postgresql/psycopg.py +++ b/lib/sqlalchemy/dialects/postgresql/psycopg.py @@ -173,7 +173,6 @@ from __future__ import annotations import collections import logging -import re from types import NoneType from typing import cast from typing import TYPE_CHECKING @@ -368,6 +367,8 @@ def _log_notices(diagnostic): class PGDialect_psycopg(_PGDialect_common_psycopg): driver = "psycopg" + minimum_dbapi_version = util.VersionInfo((3, 0, 2)) + supports_statement_cache = True supports_server_side_cursors = True default_paramstyle = "pyformat" @@ -380,7 +381,6 @@ class PGDialect_psycopg(_PGDialect_common_psycopg): execution_ctx_cls = PGExecutionContext_psycopg statement_compiler = PGCompiler_psycopg preparer = PGIdentifierPreparer_psycopg - psycopg_version = (0, 0) _has_native_hstore = True _psycopg_adapters_map = None @@ -410,21 +410,21 @@ class PGDialect_psycopg(_PGDialect_common_psycopg): }, ) + @property + def psycopg_version(self): + """Legacy accessor for :attr:`.Dialect.dbapi_version`. + + Retained for backwards compatibility; ``(0, 0)`` is returned when + no version can be determined. + + """ + version = self._dbapi_version_or_none + return version if version is not None else (0, 0) + def __init__(self, **kwargs): super().__init__(**kwargs) if self.dbapi: - m = re.match(r"(\d+)\.(\d+)(?:\.(\d+))?", self.dbapi.__version__) - if m: - self.psycopg_version = tuple( - int(x) for x in m.group(1, 2, 3) if x is not None - ) - - if self.psycopg_version < (3, 0, 2): - raise ImportError( - "psycopg version 3.0.2 or higher is required." - ) - from psycopg.adapt import AdaptersMap self._psycopg_adapters_map = adapters_map = AdaptersMap( diff --git a/lib/sqlalchemy/dialects/postgresql/psycopg2.py b/lib/sqlalchemy/dialects/postgresql/psycopg2.py index 6035c79ed9..ee9d78e623 100644 --- a/lib/sqlalchemy/dialects/postgresql/psycopg2.py +++ b/lib/sqlalchemy/dialects/postgresql/psycopg2.py @@ -488,7 +488,6 @@ from __future__ import annotations import collections.abc as collections_abc import logging -import re from typing import cast from . import ranges @@ -605,6 +604,8 @@ class ExecutemanyMode(FastIntFlag): class PGDialect_psycopg2(_PGDialect_common_psycopg): driver = "psycopg2" + minimum_dbapi_version = util.VersionInfo((2, 7)) + supports_statement_cache = True supports_server_side_cursors = True @@ -613,7 +614,6 @@ class PGDialect_psycopg2(_PGDialect_common_psycopg): supports_sane_multi_rowcount = False execution_ctx_cls = PGExecutionContext_psycopg2 preparer = PGIdentifierPreparer_psycopg2 - psycopg2_version = (0, 0) use_insertmanyvalues_wo_returning = True returns_native_bytes = False @@ -663,17 +663,16 @@ class PGDialect_psycopg2(_PGDialect_common_psycopg): self.executemany_batch_page_size = executemany_batch_page_size - if self.dbapi and hasattr(self.dbapi, "__version__"): - m = re.match(r"(\d+)\.(\d+)(?:\.(\d+))?", self.dbapi.__version__) - if m: - self.psycopg2_version = tuple( - int(x) for x in m.group(1, 2, 3) if x is not None - ) + @property + def psycopg2_version(self): + """Legacy accessor for :attr:`.Dialect.dbapi_version`. - if self.psycopg2_version < (2, 7): - raise ImportError( - "psycopg2 version 2.7 or higher is required." - ) + Retained for backwards compatibility; ``(0, 0)`` is returned when + no version can be determined. + + """ + version = self._dbapi_version_or_none + return version if version is not None else (0, 0) def initialize(self, connection): super().initialize(connection) diff --git a/lib/sqlalchemy/dialects/sqlite/aiosqlite.py b/lib/sqlalchemy/dialects/sqlite/aiosqlite.py index 7b9657bbee..2a5f712173 100644 --- a/lib/sqlalchemy/dialects/sqlite/aiosqlite.py +++ b/lib/sqlalchemy/dialects/sqlite/aiosqlite.py @@ -124,6 +124,7 @@ from typing import Union from .base import SQLiteExecutionContext from .pysqlite import SQLiteDialect_pysqlite from ... import pool +from ... import util from ...connectors.asyncio import AsyncAdapt_dbapi_connection from ...connectors.asyncio import AsyncAdapt_dbapi_cursor from ...connectors.asyncio import AsyncAdapt_dbapi_module @@ -316,6 +317,14 @@ class SQLiteDialect_aiosqlite(SQLiteDialect_pysqlite): __import__("aiosqlite"), __import__("sqlite3") ) + def retrieve_dbapi_version(self, dbapi: DBAPIModule) -> util.VersionInfo: + # the version of aiosqlite, rather than the Python version + # reported by the pysqlite dialect + aiosqlite = getattr(dbapi, "aiosqlite", None) + return util.parse_version_string( + getattr(aiosqlite, "__version__", None) + ) + @classmethod def get_pool_class(cls, url: URL) -> type[pool.Pool]: if cls._is_url_file_db(url): diff --git a/lib/sqlalchemy/dialects/sqlite/pysqlcipher.py b/lib/sqlalchemy/dialects/sqlite/pysqlcipher.py index f294a66ee2..e30be35429 100644 --- a/lib/sqlalchemy/dialects/sqlite/pysqlcipher.py +++ b/lib/sqlalchemy/dialects/sqlite/pysqlcipher.py @@ -100,6 +100,7 @@ time, at the expense of slower startup time for new connections. from .pysqlite import SQLiteDialect_pysqlite from ... import pool +from ... import util class SQLiteDialect_pysqlcipher(SQLiteDialect_pysqlite): @@ -121,6 +122,11 @@ class SQLiteDialect_pysqlcipher(SQLiteDialect_pysqlite): return sqlcipher + def retrieve_dbapi_version(self, dbapi): + # the version of sqlcipher3 / pysqlcipher3, rather than the Python + # version reported by the pysqlite dialect + return util.parse_version_string(getattr(dbapi, "version", None)) + @classmethod def get_pool_class(cls, url): return pool.SingletonThreadPool diff --git a/lib/sqlalchemy/dialects/sqlite/pysqlite.py b/lib/sqlalchemy/dialects/sqlite/pysqlite.py index 0d22182bf8..54fe3b1701 100644 --- a/lib/sqlalchemy/dialects/sqlite/pysqlite.py +++ b/lib/sqlalchemy/dialects/sqlite/pysqlite.py @@ -473,6 +473,7 @@ from __future__ import annotations import math import os import re +import sys from typing import Any from typing import Callable from typing import cast @@ -497,7 +498,7 @@ if TYPE_CHECKING: from ...engine.interfaces import DBAPICursor from ...engine.interfaces import DBAPIModule from ...engine.interfaces import IsolationLevel - from ...engine.interfaces import VersionInfoType + from ...engine.interfaces import ServerVersionInfoType from ...engine.url import URL from ...pool.base import PoolProxiedConnection from ...sql.type_api import _BindProcessorType @@ -579,9 +580,20 @@ class SQLiteDialect_pysqlite(SQLiteDialect): else: return pool.SingletonThreadPool - def _get_server_version_info(self, connection: Any) -> VersionInfoType: + def _get_server_version_info( + self, connection: Any + ) -> ServerVersionInfoType: return self.dbapi.sqlite_version_info # type: ignore[no-any-return, union-attr] # noqa: E501 + def retrieve_dbapi_version(self, dbapi: DBAPIModule) -> util.VersionInfo: + # the ``sqlite3`` module ships with CPython and has no version of + # its own (the legacy ``sqlite3.version`` attribute was frozen at + # 2.6.0 and removed in Python 3.14), so the Python version is used + # here, which is what its feature set actually tracks. The version + # of the SQLite library itself is available as + # :attr:`.Dialect.server_version_info`. + return util.VersionInfo(sys.version_info[:3]) + _isolation_lookup = SQLiteDialect._isolation_lookup.union( { "AUTOCOMMIT": None, diff --git a/lib/sqlalchemy/engine/default.py b/lib/sqlalchemy/engine/default.py index e129086807..3903b935d1 100644 --- a/lib/sqlalchemy/engine/default.py +++ b/lib/sqlalchemy/engine/default.py @@ -527,6 +527,8 @@ class DefaultDialect(Dialect): if insertmanyvalues_page_size is not _NoArg.NO_ARG: self.insertmanyvalues_page_size = insertmanyvalues_page_size + self._check_minimum_dbapi_version() + @property @util.deprecated( "2.0", @@ -584,12 +586,86 @@ class DefaultDialect(Dialect): @util.memoized_property def loaded_dbapi(self) -> DBAPIModule: if self.dbapi is None: - raise exc.InvalidRequestError( + raise exc.NoDBAPILoaded( f"Dialect {self} does not have a Python DBAPI established " "and cannot be used for actual database interaction" ) return self.dbapi + @util.memoized_property + def dbapi_version(self) -> util.VersionInfo: + # memoization applies to a successfully determined version only; + # memoized_property does not cache when the function raises, so a + # DBAPI which is established after this dialect was constructed is + # still picked up + if self.dbapi is None: + raise exc.NoDBAPILoaded( + f"Dialect {self.name}+{self.driver} has no DBAPI module " + "loaded; no DBAPI version is available" + ) + + try: + version = self.retrieve_dbapi_version(self.dbapi) + except NotImplementedError as ne: + raise NotImplementedError( + f"Dialect {self.name}+{self.driver} does not implement " + "retrieve_dbapi_version(); no DBAPI version is available" + ) from ne + + if not version: + # the DBAPI is loaded but publishes no version of its own; as + # with a DBAPI that isn't loaded, this is not an error on the + # part of the dialect + # asyncio dialects have a wrapper object here rather than a + # module, which has no __name__ + dbapi_name = getattr(self.dbapi, "__name__", self.driver) + raise exc.NoDBAPILoaded( + f"Dialect {self.name}+{self.driver} could not determine a " + f"version for its DBAPI module {dbapi_name!r}" + ) + + return version + + @property + def _dbapi_version_or_none(self) -> Optional[util.VersionInfo]: + """:attr:`.Dialect.dbapi_version`, or None if it can't be + determined. + + For use by dialect startup checks, which run before a DBAPI is + necessarily present and which must not fail when no version is + available. Only :class:`.exc.NoDBAPILoaded` is accommodated; + ``NotImplementedError``, indicating a dialect which does not + implement :meth:`.Dialect.retrieve_dbapi_version` at all, is a bug + in that dialect and is allowed to propagate. Deliberately not + memoized itself; the underlying :attr:`.Dialect.dbapi_version` + memoizes the success case. + + """ + try: + return self.dbapi_version + except exc.NoDBAPILoaded: + return None + + def _check_minimum_dbapi_version(self) -> None: + """Enforce :attr:`.Dialect.minimum_dbapi_version`, if present. + + Takes place as the dialect is constructed. No check occurs when + the version of the DBAPI is not available at all. + + """ + minimum = self.minimum_dbapi_version + if minimum is None: + return + + version = self._dbapi_version_or_none + if version is not None and version < minimum: + dbapi_name = getattr(self.dbapi, "__name__", self.driver) + raise exc.InvalidRequestError( + f"Dialect {self.name}+{self.driver} requires version " + f"{minimum} or greater of the {dbapi_name} DBAPI; " + f"version {version} is installed" + ) + @util.memoized_property def _bind_typing_render_casts(self): return self.bind_typing is interfaces.BindTyping.RENDER_CASTS diff --git a/lib/sqlalchemy/engine/interfaces.py b/lib/sqlalchemy/engine/interfaces.py index 0109e3b4f5..50dc10e9af 100644 --- a/lib/sqlalchemy/engine/interfaces.py +++ b/lib/sqlalchemy/engine/interfaces.py @@ -636,7 +636,18 @@ class BindTyping(Enum): """ -VersionInfoType = Tuple[Union[int, str], ...] +ServerVersionInfoType = Tuple[Union[int, str], ...] +"""The type of :attr:`.Dialect.server_version_info`. + +.. versionadded:: 2.1 Renamed from ``VersionInfoType``, which remains + present as a synonym. The version of the DBAPI, as opposed to that of + the database server, is instead a ``sqlalchemy.util.VersionInfo``; see + :attr:`.Dialect.dbapi_version`. + +""" + +VersionInfoType = ServerVersionInfoType + TableKey = Tuple[Optional[str], str] @@ -757,13 +768,119 @@ class Dialect(EventTarget): """ - server_version_info: Optional[Tuple[Any, ...]] + server_version_info: Optional[ServerVersionInfoType] """a tuple containing a version number for the DB backend in use. This value is only available for supporting dialects, and is typically populated during the initial connection to the database. """ + minimum_dbapi_version: Optional[util.VersionInfo] = None + """The minimum version of the DBAPI which this dialect supports. + + When present, :class:`.DefaultDialect` compares this against + :attr:`.Dialect.dbapi_version` as the dialect is constructed, raising + :class:`.exc.InvalidRequestError` if the DBAPI in use is older. A + dialect therefore does not need to implement this check itself:: + + class MyDialect(DefaultDialect): + minimum_dbapi_version = util.VersionInfo((2, 5)) + + No check takes place if the version of the DBAPI is not available, as + described at :attr:`.Dialect.dbapi_version`. + + .. versionadded:: 2.1 + + """ + + @property + def dbapi_version(self) -> util.VersionInfo: + """the version number of the DBAPI in use. + + In contrast to :attr:`.Dialect.server_version_info`, which refers to + the database server itself, this attribute refers to the version of + the Python DBAPI module which the dialect makes use of, and is + available without any database connection being established. + + The value is a ``sqlalchemy.util.VersionInfo``, a tuple of integers + which additionally sorts pre-release versions such as ``2.0.0rc1`` + as preceding the final release ``(2, 0, 0)``. It may be compared + against a plain tuple of integers directly:: + + if dialect.dbapi_version >= (2, 5): + ... + + Dialects should implement + :meth:`.Dialect.retrieve_dbapi_version` only in order to provide + this value; this method in turn is used by the + :class:`.DefaultDialect` implementation of + :attr:`.DefaultDialect.dbapi_version`. + + Two distinct conditions prevent a version from being available: + + * :class:`.exc.NoDBAPILoaded` is raised if the dialect has no DBAPI + module loaded, as is the case for a dialect used only to compile + statements, or if its DBAPI publishes no version of its own. + Neither is an error on the part of the dialect. + + * ``NotImplementedError`` is raised if the dialect does not + implement :meth:`.Dialect.retrieve_dbapi_version` at all. This + indicates the dialect itself needs to be fixed. + + Consuming code which tolerates a dialect that has not loaded a + DBAPI should accommodate the former only, so that a dialect in need + of fixing continues to make itself known:: + + try: + dbapi_version = dialect.dbapi_version + except exc.NoDBAPILoaded: + dbapi_version = None + + Note that ``hasattr()`` may **not** be used to test for support, as + it does not intercept ``NotImplementedError``. Note also that + reading this attribute does not cause a DBAPI module to be + imported; it reports upon the module already in use, if any. + + A version, once determined, is memoized. As no memoization takes + place while the version remains unavailable, a DBAPI which is + established after the dialect was constructed is still detected. + + .. versionadded:: 2.1 + + .. seealso:: + + :meth:`.Dialect.retrieve_dbapi_version` + + """ + raise NotImplementedError() + + def retrieve_dbapi_version(self, dbapi: DBAPIModule) -> util.VersionInfo: + """Return the version of the given DBAPI module. + + This is the dialect-implemented hook behind + :attr:`.Dialect.dbapi_version`. A dialect is responsible only for + locating where its particular DBAPI publishes a version and parsing + it, typically using ``sqlalchemy.util.parse_version_string()``:: + + def retrieve_dbapi_version(self, dbapi): + return util.parse_version_string(dbapi.__version__) + + The ``dbapi`` argument is the module returned by + :meth:`.Dialect.import_dbapi`, which for asyncio dialects is + typically a wrapper object rather than the driver module itself. + + This method is only invoked with a DBAPI actually loaded, and only + until a version has been determined; the surrounding conditions, + including memoization, are handled by :class:`.DefaultDialect`. An + empty version may be returned to indicate that no version could be + located, which :attr:`.Dialect.dbapi_version` translates into + :class:`.exc.NoDBAPILoaded`. + + .. versionadded:: 2.1 + + """ + raise NotImplementedError() + default_schema_name: Optional[str] """the name of the default schema. This value is only available for supporting dialects, and is typically populated during the diff --git a/lib/sqlalchemy/exc.py b/lib/sqlalchemy/exc.py index ed400aa9a7..c91930e739 100644 --- a/lib/sqlalchemy/exc.py +++ b/lib/sqlalchemy/exc.py @@ -347,6 +347,20 @@ class NoInspectionAvailable(InvalidRequestError): no context for inspection.""" +class NoDBAPILoaded(InvalidRequestError): + """A DBAPI-level attribute was requested from a dialect which has no + DBAPI module loaded, or whose DBAPI does not publish the attribute. + + This is not an error on the part of the dialect; a dialect which is + used only to compile statements, rather than to interact with a + database, has no DBAPI established, and a DBAPI module is not obliged + to publish a version number of its own. + + .. versionadded:: 2.1 + + """ + + class PendingRollbackError(InvalidRequestError): """A transaction has failed and needs to be rolled back before continuing. diff --git a/lib/sqlalchemy/testing/exclusions.py b/lib/sqlalchemy/testing/exclusions.py index d1bdd2e304..884bb3e6bc 100644 --- a/lib/sqlalchemy/testing/exclusions.py +++ b/lib/sqlalchemy/testing/exclusions.py @@ -273,6 +273,30 @@ class BooleanPredicate(Predicate): class SpecPredicate(Predicate): + """Predicate against a database and optional version. + + The ``db`` string is of the form ````, ``+`` + or ``+``, optionally followed by a comparison operator and a + dotted version number. + + When a version comparison is present, it applies to the **server** + version when no driver is named, and to the **DBAPI** version, i.e. + :attr:`.Dialect.dbapi_version`, when a driver is named:: + + # server is older than PostgreSQL 12 + "postgresql<12" + + # the asyncmy DBAPI is older than 0.2.13 + "+asyncmy<0.2.13" + + Versions with pre-release qualifiers sort as expected, so that + ``0.2.13rc1`` matches ``+asyncmy<0.2.13``. + + .. versionadded:: 2.1 Version comparison against a named driver, which + previously raised an assertion error. + + """ + def __init__(self, db, op=None, spec=None, description=None): self.db = db self.op = op @@ -307,9 +331,10 @@ class SpecPredicate(Predicate): return False if self.op is not None: - assert driver is None, "DBAPI version specs not supported yet" - - version = _server_version(engine) + if driver is not None: + version = engine.dialect.dbapi_version + else: + version = _server_version(engine) oper = ( hasattr(self.op, "__call__") and self.op or self._ops[self.op] ) diff --git a/lib/sqlalchemy/util/__init__.py b/lib/sqlalchemy/util/__init__.py index 183a6a1f20..2572937a4f 100644 --- a/lib/sqlalchemy/util/__init__.py +++ b/lib/sqlalchemy/util/__init__.py @@ -135,6 +135,10 @@ from .langhelpers import only_once as only_once from .langhelpers import ( parse_user_argument_for_enum as parse_user_argument_for_enum, ) +from .langhelpers import ( + parse_version_from_metadata as parse_version_from_metadata, +) +from .langhelpers import parse_version_string as parse_version_string from .langhelpers import PluginLoader as PluginLoader from .langhelpers import quoted_token_parser as quoted_token_parser from .langhelpers import restore_annotations as restore_annotations @@ -149,6 +153,7 @@ from .langhelpers import TypingOnly as TypingOnly from .langhelpers import ( unbound_method_to_callable as unbound_method_to_callable, ) +from .langhelpers import VersionInfo as VersionInfo from .langhelpers import walk_subclasses as walk_subclasses from .langhelpers import warn as warn from .langhelpers import warn_exception as warn_exception diff --git a/lib/sqlalchemy/util/langhelpers.py b/lib/sqlalchemy/util/langhelpers.py index 20c4979064..eb47ed3042 100644 --- a/lib/sqlalchemy/util/langhelpers.py +++ b/lib/sqlalchemy/util/langhelpers.py @@ -16,6 +16,7 @@ from __future__ import annotations import collections import enum from functools import update_wrapper +import importlib.metadata import importlib.util import inspect import itertools @@ -2315,6 +2316,268 @@ def load_uncompiled_module(module: _M) -> _M: return cast(_M, py_module) +_pre_release_normalize = { + "a": "a", + "alpha": "a", + "b": "b", + "beta": "b", + "c": "rc", + "pre": "rc", + "preview": "rc", + "rc": "rc", +} + +_version_string_re = re.compile( + r""" + \s* + (?:[a-z][a-z0-9]*[-_])? # ignored prefix, "py3-" + v? + (?P\d+(?:\.\d+)*) + (?: # pre-release + [-_.]? + (?Palpha|beta|preview|pre|rc|a|b|c) + [-_.]? + (?P\d+)? + )? + (?: # post-release + [-_.]? + (?Ppost|rev|r) + [-_.]? + (?P\d+)? + )? + (?: # developmental release + [-_.]? + (?Pdev) + [-_.]? + (?P\d+)? + )? + """, + re.X | re.I, +) + +_VersionSortKey = Tuple[ + Tuple[int, ...], + Tuple[int, str, int], + Tuple[int, int], + Tuple[int, int], +] + + +def _version_sort_key( + release: Tuple[int, ...], + pre: Optional[Tuple[str, int]], + post: Optional[int], + dev: Optional[int], +) -> _VersionSortKey: + if pre is None and post is None and dev is not None: + # a dev release with no other qualifiers precedes every + # pre-release of the same release number + pre_key = (-1, "", 0) + elif pre is None: + pre_key = (1, "", 0) + else: + pre_key = (0, pre[0], pre[1]) + + return ( + release, + pre_key, + (0, 0) if post is None else (1, post), + (1, 0) if dev is None else (0, dev), + ) + + +def _version_comparison( + op: Callable[[Any, Any], bool], +) -> Callable[[VersionInfo, Any], Any]: + """Build one of :class:`.VersionInfo`'s comparison methods. + + Comparison takes place against the sort key rather than the tuple + itself, so that pre-release and similar qualifiers are taken into + account. A plain tuple is interpreted as the release segment of a + final release; anything else is not comparable. + + """ + + def compare(self: VersionInfo, other: Any) -> Any: + if isinstance(other, VersionInfo): + other_key = other._sort_key + elif isinstance(other, tuple): + other_key = _version_sort_key(other, None, None, None) + else: + return NotImplemented + return op(self._sort_key, other_key) + + return compare + + +class VersionInfo(Tuple[int, ...]): + """A version number, as a tuple of integers. + + :class:`.VersionInfo` is a ``tuple`` subclass consisting of the + numeric "release" segment of a version only, e.g. ``2.0.0rc1`` + is the tuple ``(2, 0, 0)``. Ordering however takes any + pre-release, post-release and developmental qualifiers into account + as described by :pep:`440`, so that ``2.0.0rc1`` compares as less than + ``2.0.0``, including when compared against a plain tuple such as + ``(2, 0, 0)``. + + Plain tuples are interpreted as final releases when compared against + a :class:`.VersionInfo`. + + .. versionadded:: 2.1 + + """ + + string: Optional[str] + """the string from which this version was parsed, if any.""" + + pre: Optional[Tuple[str, int]] + """normalized pre-release qualifier, e.g. ``("rc", 1)``.""" + + post: Optional[int] + """post-release number, if any.""" + + dev: Optional[int] + """developmental release number, if any.""" + + _sort_key: _VersionSortKey + + def __new__( + cls, + release: Sequence[int] = (), + *, + string: Optional[str] = None, + pre: Optional[Tuple[str, int]] = None, + post: Optional[int] = None, + dev: Optional[int] = None, + ) -> VersionInfo: + # __new__ is needed as the release segment has to be passed to + # tuple.__new__(); the remaining state is set up in __init__ + return tuple.__new__(cls, release) + + def __init__( + self, + release: Sequence[int] = (), + *, + string: Optional[str] = None, + pre: Optional[Tuple[str, int]] = None, + post: Optional[int] = None, + dev: Optional[int] = None, + ): + self.string = string + self.pre = pre + self.post = post + self.dev = dev + self._sort_key = _version_sort_key(tuple(self), pre, post, dev) + + def __repr__(self) -> str: + if self.string is not None: + return f"VersionInfo({tuple(self)!r}, string={self.string!r})" + else: + return f"VersionInfo({tuple(self)!r})" + + def __str__(self) -> str: + if self.string is not None: + return self.string + else: + return ".".join(str(num) for num in self) + + # every comparison has to be stated explicitly; ``tuple`` implements + # all six of them, so ``functools.total_ordering`` fills in nothing + # here and the ones left out would silently compare as plain tuples + __eq__ = _version_comparison(operator.eq) + __ne__ = _version_comparison(operator.ne) + __lt__ = _version_comparison(operator.lt) + __le__ = _version_comparison(operator.le) + __gt__ = _version_comparison(operator.gt) + __ge__ = _version_comparison(operator.ge) + + def __hash__(self) -> int: + return hash(self._sort_key) + + +def parse_version_string(version: Optional[str]) -> VersionInfo: + """Parse a DBAPI version string into a :class:`.VersionInfo`. + + Leading characters that are not part of the version itself are + ignored, as are trailing characters following the version, so that + strings such as ``"py3-4.0.19-beta4"`` and + ``"2.9.10 (dt dec pq3 ext lo64)"`` parse correctly. + + An empty :class:`.VersionInfo` is returned if no version number can be + located at all. + + Parsing is deliberately more tolerant than that of :pep:`440`, which + the version strings published by DBAPIs frequently do not conform to; + a strict implementation such as that of the ``packaging`` library + rejects each of the above outright. + + .. versionadded:: 2.1 + + """ + + if not version: + return VersionInfo((), string=version) + + m = _version_string_re.match(version) + if m is None: + return VersionInfo((), string=version) + + release = tuple(int(x) for x in m.group("release").split(".")) + + pre_l = m.group("pre_l") + pre: Optional[Tuple[str, int]] + if pre_l is not None: + pre = ( + _pre_release_normalize[pre_l.lower()], + int(m.group("pre_n") or 0), + ) + else: + pre = None + + return VersionInfo( + release, + string=version, + pre=pre, + post=( + int(m.group("post_n") or 0) + if m.group("post_l") is not None + else None + ), + dev=( + int(m.group("dev_n") or 0) + if m.group("dev_l") is not None + else None + ), + ) + + +def parse_version_from_metadata(distribution: str) -> VersionInfo: + """Return the version of an installed distribution as a + :class:`.VersionInfo`. + + This is intended for use by dialects whose DBAPI module does not + itself publish a version number, such as ``asyncmy``. As the + distribution name is not necessarily the same as the module name, and + the installed distribution is not necessarily the module that was + imported, this should not be used when the DBAPI module provides a + version of its own. + + An empty :class:`.VersionInfo` is returned if the distribution is not + installed. + + .. versionadded:: 2.1 + + """ + + try: + version = importlib.metadata.version(distribution) + except importlib.metadata.PackageNotFoundError: + return VersionInfo() + else: + return parse_version_string(version) + + class _Missing(enum.Enum): Missing = enum.auto() diff --git a/test/base/test_except.py b/test/base/test_except.py index 44f7931a11..fc4ef4ea64 100644 --- a/test/base/test_except.py +++ b/test/base/test_except.py @@ -453,6 +453,7 @@ ALL_EXC = [ sa_exceptions.TimeoutError, sa_exceptions.InvalidRequestError, sa_exceptions.IllegalStateChangeError, + sa_exceptions.NoDBAPILoaded, sa_exceptions.NoInspectionAvailable, sa_exceptions.PendingRollbackError, sa_exceptions.ResourceClosedError, diff --git a/test/base/test_utils.py b/test/base/test_utils.py index 2a45059384..72fe768cde 100644 --- a/test/base/test_utils.py +++ b/test/base/test_utils.py @@ -1,6 +1,8 @@ import copy from decimal import Decimal +import importlib.metadata import inspect +import operator from pathlib import Path import pickle import sys @@ -3561,6 +3563,170 @@ class QuotedTokenParserTest(fixtures.TestBase): self._test('"na.me"', ["na.me"]) +class ParseVersionStringTest(fixtures.TestBase): + @combinations( + ("2.1.8", (2, 1, 8), None, None, None), + ("10.15.17", (10, 15, 17), None, None, None), + ("v1.2.3", (1, 2, 3), None, None, None), + ("1.4", (1, 4), None, None, None), + # pyodbc style, with a prefix and a spelled out pre-release + ("py3-3.0.1-beta4", (3, 0, 1), ("b", 4), None, None), + ("py3-4.0.19", (4, 0, 19), None, None, None), + # psycopg2 style, with trailing information + ("2.9.10 (dt dec pq3 ext lo64)", (2, 9, 10), None, None, None), + # pep 484 pre-release, post-release, developmental release + ("2.0.0a1", (2, 0, 0), ("a", 1), None, None), + ("2.0.0b1", (2, 0, 0), ("b", 1), None, None), + ("2.0.0rc1", (2, 0, 0), ("rc", 1), None, None), + ("2.0.0c1", (2, 0, 0), ("rc", 1), None, None), + ("2.0.0-alpha", (2, 0, 0), ("a", 0), None, None), + ("2.0.0.post2", (2, 0, 0), None, 2, None), + ("2.0.0.dev3", (2, 0, 0), None, None, 3), + ("2.0.0b1.dev3", (2, 0, 0), ("b", 1), None, 3), + # no version at all + ("crap.crap.crap", (), None, None, None), + ("", (), None, None, None), + (None, (), None, None, None), + argnames="version, release, pre, post, dev", + ) + def test_parse(self, version, release, pre, post, dev): + parsed = util.parse_version_string(version) + eq_( + ( + tuple(parsed), + parsed.pre, + parsed.post, + parsed.dev, + parsed.string, + ), + (release, pre, post, dev, version), + ) + + @combinations( + # a VersionInfo compares equal to the plain tuple of its release + # segment when it has no pre / post / dev qualifiers + ("2.0.0", "==", (2, 0, 0)), + ("2.0.0", "<", (2, 0, 1)), + ("2.0.0", ">", (1, 9, 9)), + ("0.2.12", "<", (0, 2, 13)), + ("0.2.13", "==", (0, 2, 13)), + # pre-releases precede the release they qualify, including when + # compared against a plain tuple + ("2.0.0b1", "<", (2, 0, 0)), + ("0.2.13rc1", "<", (0, 2, 13)), + ("2.0.0.dev1", "<", (2, 0, 0)), + # post-releases follow it + ("2.0.0.post1", ">", (2, 0, 0)), + argnames="version, op, other", + ) + def test_compare_to_tuple(self, version, op, other): + """all six comparisons consult the sort key. + + ``tuple`` implements every one of them, so an operator which + VersionInfo fails to state explicitly silently compares as a plain + tuple and gets the pre-release cases wrong. + + """ + + parsed = util.parse_version_string(version) + eq_( + ( + parsed < other, + parsed <= other, + parsed == other, + parsed != other, + parsed >= other, + parsed > other, + ), + ( + op == "<", + op in ("<", "=="), + op == "==", + op != "==", + op in (">", "=="), + op == ">", + ), + ) + + def test_reflected_comparison_to_tuple(self): + """a plain tuple on the left still gets VersionInfo semantics. + + VersionInfo is a tuple subclass, so Python tries its reflected + operation first. + + """ + + parsed = util.parse_version_string("2.0.0rc1") + + # (2, 0, 0) is greater than 2.0.0rc1 + eq_( + ( + (2, 0, 0) < parsed, + (2, 0, 0) <= parsed, + (2, 0, 0) == parsed, + (2, 0, 0) != parsed, + (2, 0, 0) >= parsed, + (2, 0, 0) > parsed, + ), + (False, False, False, True, True, True), + ) + + @combinations("<", "<=", ">=", ">", argnames="op") + def test_not_comparable(self, op): + oper = { + "<": operator.lt, + "<=": operator.le, + ">=": operator.ge, + ">": operator.gt, + }[op] + + with expect_raises(TypeError): + oper(util.parse_version_string("2.0.0"), "2.0.0") + + def test_pep440_ordering(self): + """dev < alpha < beta < rc < final < post""" + + versions = [ + "2.0.0.dev1", + "2.0.0a1", + "2.0.0a2", + "2.0.0b1", + "2.0.0rc1", + "2.0.0", + "2.0.0.post1", + "2.0.1", + ] + parsed = [util.parse_version_string(v) for v in versions] + eq_(sorted(reversed(parsed)), parsed) + + def test_hash(self): + eq_( + {util.parse_version_string("2.0.0b1")}, + {util.parse_version_string("2.0.0b1")}, + ) + ne_( + hash(util.parse_version_string("2.0.0b1")), + hash(util.parse_version_string("2.0.0")), + ) + + def test_compare_to_non_tuple(self): + parsed = util.parse_version_string("2.0.0") + is_false(parsed == "2.0.0") + with expect_raises(TypeError): + parsed < "2.0.0" + + def test_from_metadata(self): + # pytest is necessarily installed for this test to be running at + # all; greenlet and the like are optional + eq_( + util.parse_version_from_metadata("pytest"), + util.parse_version_string(importlib.metadata.version("pytest")), + ) + + def test_from_metadata_not_installed(self): + eq_(util.parse_version_from_metadata("no_such_distribution"), ()) + + class BackslashReplaceTest(fixtures.TestBase): def test_ascii_to_utf8(self): eq_( diff --git a/test/dialect/mysql/test_dialect.py b/test/dialect/mysql/test_dialect.py index b528f48571..e69af8b54e 100644 --- a/test/dialect/mysql/test_dialect.py +++ b/test/dialect/mysql/test_dialect.py @@ -12,6 +12,8 @@ from sqlalchemy import select from sqlalchemy import Table from sqlalchemy import testing from sqlalchemy.dialects import mysql +from sqlalchemy.dialects.mysql import mysqldb +from sqlalchemy.dialects.mysql import pymysql from sqlalchemy.dialects.mysql.pymysql import _connection_ping_reconnects_true from sqlalchemy.engine.url import make_url from sqlalchemy.testing import assert_raises_message @@ -513,6 +515,51 @@ class DialectTest(fixtures.TestBase): eq_(detected, "utf8mb4") +class DBAPIVersionTest(fixtures.TestBase): + """test :meth:`.Dialect.retrieve_dbapi_version` for the mysqldb family + of dialects.""" + + def test_mysqlclient(self): + """mysqlclient publishes version_info only""" + + dbapi = mock.Mock(spec=["version_info", "paramstyle"]) + dbapi.version_info = (2, 2, 7, "final", 0) + dbapi.paramstyle = "format" + + dialect = mysqldb.MySQLDialect_mysqldb(dbapi=dbapi) + eq_(dialect.dbapi_version, (2, 2, 7)) + + def test_mysql_python(self): + """the legacy MySQL-python published __version__""" + + dbapi = mock.Mock(spec=["__version__", "paramstyle"]) + dbapi.__version__ = "1.2.5" + dbapi.paramstyle = "format" + + dialect = mysqldb.MySQLDialect_mysqldb(dbapi=dbapi) + eq_(dialect.dbapi_version, (1, 2, 5)) + + def test_pymysql(self): + """pymysql's __version__ / version_info are mysqlclient + compatibility values; VERSION_STRING is its own""" + + dbapi = mock.Mock( + spec=[ + "VERSION_STRING", + "__version__", + "version_info", + "paramstyle", + ] + ) + dbapi.VERSION_STRING = "1.2.0" + dbapi.__version__ = "2.2.8" + dbapi.version_info = (2, 2, 8, "final", 1) + dbapi.paramstyle = "format" + + dialect = pymysql.MySQLDialect_pymysql(dbapi=dbapi) + eq_(dialect.dbapi_version, (1, 2, 0)) + + class ParseVersionTest(fixtures.TestBase): def test_mariadb_madness(self): mysql_dialect = make_url("mysql+mysqldb://").get_dialect()() diff --git a/test/dialect/oracle/test_dialect.py b/test/dialect/oracle/test_dialect.py index 6ea6fe0c3c..dba00895ce 100644 --- a/test/dialect/oracle/test_dialect.py +++ b/test/dialect/oracle/test_dialect.py @@ -47,21 +47,26 @@ from sqlalchemy.util import greenlet_spawn class CxOracleDialectTest(fixtures.TestBase): def test_cx_oracle_version_parse(self): - dialect = cx_oracle.OracleDialect_cx_oracle() - def check(version): - dbapi = Mock(version=version) - dialect._load_version(dbapi) + # a new dialect per version; dbapi_version is memoized + dialect = cx_oracle.OracleDialect_cx_oracle( + dbapi=Mock(version=version) + ) return dialect.cx_oracle_ver eq_(check("8.2"), (8, 2)) eq_(check("8.0.1"), (8, 0, 1)) - eq_(check("9.0b1"), (9, 0)) + + # a pre-release sorts before the release it qualifies + beta = check("9.0b1") + eq_(tuple(beta), (9, 0)) + is_true(beta < (9, 0)) def test_minimum_version(self): with expect_raises_message( exc.InvalidRequestError, - "cx_Oracle version 8 and above are supported", + r"Dialect oracle\+cx_oracle requires version 8 or greater of " + r"the cx_oracle DBAPI; version 5.1.5 is installed", ): cx_oracle.OracleDialect_cx_oracle(dbapi=Mock(version="5.1.5")) @@ -75,21 +80,26 @@ class OracleDbDialectTest(fixtures.TestBase): __only_on__ = "oracle+oracledb" def test_oracledb_version_parse(self): - dialect = oracledb.OracleDialect_oracledb() - def check(version): - dbapi = Mock(version=version) - dialect._load_version(dbapi) + # a new dialect per version; dbapi_version is memoized + dialect = oracledb.OracleDialect_oracledb( + dbapi=Mock(version=version) + ) return dialect.oracledb_ver eq_(check("7.2"), (7, 2)) eq_(check("7.0.1"), (7, 0, 1)) - eq_(check("9.0b1"), (9, 0)) + + # a pre-release sorts before the release it qualifies + beta = check("9.0b1") + eq_(tuple(beta), (9, 0)) + is_true(beta < (9, 0)) def test_minimum_version(self): with expect_raises_message( exc.InvalidRequestError, - r"oracledb version \(1,\) and above are supported", + r"Dialect oracle\+oracledb requires version 1 or greater of " + r"the oracledb DBAPI; version 0.1.5 is installed", ): oracledb.OracleDialect_oracledb(dbapi=Mock(version="0.1.5")) @@ -99,7 +109,8 @@ class OracleDbDialectTest(fixtures.TestBase): def test_async_minimum_version(self): with expect_raises_message( exc.InvalidRequestError, - r"oracledb version \(2, 0, 1\) and above are supported", + r"Dialect oracle\+oracledb requires version 2.0.1 or greater " + r"of the oracledb DBAPI; version 2.0.0 is installed", ): oracledb.OracleDialectAsync_oracledb(dbapi=Mock(version="2.0.0")) @@ -713,7 +724,7 @@ class CompatFlagsTest(fixtures.TestBase, AssertsCompiledSQL): dialect = oracle.dialect( dbapi=Mock( - version="0.0.0", + version="2.4.0", paramstyle="named", ), **kw, diff --git a/test/dialect/test_pyodbc.py b/test/dialect/test_pyodbc.py deleted file mode 100644 index 80f1e468ab..0000000000 --- a/test/dialect/test_pyodbc.py +++ /dev/null @@ -1,15 +0,0 @@ -from sqlalchemy.connectors import pyodbc -from sqlalchemy.testing import eq_ -from sqlalchemy.testing import fixtures - - -class PyODBCTest(fixtures.TestBase): - def test_pyodbc_version(self): - connector = pyodbc.PyODBCConnector() - for vers, expected in [ - ("2.1.8", (2, 1, 8)), - ("py3-3.0.1-beta4", (3, 0, 1, "beta4")), - ("10.15.17", (10, 15, 17)), - ("crap.crap.crap", ()), - ]: - eq_(connector._parse_dbapi_version(vers), expected) diff --git a/test/engine/test_parseconnect.py b/test/engine/test_parseconnect.py index 1673916c61..254ac1ff12 100644 --- a/test/engine/test_parseconnect.py +++ b/test/engine/test_parseconnect.py @@ -1,4 +1,5 @@ import copy +import re from unittest.mock import call from unittest.mock import MagicMock from unittest.mock import Mock @@ -545,6 +546,172 @@ class DialectImportTest(fixtures.TestBase): eq_(dialect.name, name) +class DBAPIVersionTest(fixtures.TestBase): + """test :attr:`.Dialect.dbapi_version` and the + :meth:`.Dialect.retrieve_dbapi_version` hook.""" + + def _dialect_cls(self, **kw): + class MyDialect(DefaultDialect): + name = "mydialect" + driver = "mydriver" + retrieve_calls = 0 + + def retrieve_dbapi_version(self, dbapi): + MyDialect.retrieve_calls += 1 + return tsa.util.parse_version_string( + getattr(dbapi, "__version__", None) + ) + + return MyDialect + + def test_version_from_hook(self): + d = self._dialect_cls()(dbapi=Mock(__version__="1.2.3")) + eq_(d.dbapi_version, (1, 2, 3)) + + def test_memoized(self): + """the hook is invoked once for repeated reads""" + + cls = self._dialect_cls() + d = cls(dbapi=Mock(__version__="1.2.3")) + eq_([d.dbapi_version, d.dbapi_version, d.dbapi_version][0], (1, 2, 3)) + eq_(cls.retrieve_calls, 1) + + def test_no_dbapi_not_memoized(self): + """a dialect with no DBAPI raises, and picks up a DBAPI which is + established afterwards""" + + cls = self._dialect_cls() + d = cls() + + with expect_raises_message( + exc.NoDBAPILoaded, + "Dialect mydialect\\+mydriver has no DBAPI module loaded", + ): + d.dbapi_version + + eq_(cls.retrieve_calls, 0) + is_false("dbapi_version" in d.__dict__) + + d.dbapi = Mock(__version__="1.2.3") + eq_(d.dbapi_version, (1, 2, 3)) + + def test_hook_not_implemented(self): + class NoHookDialect(DefaultDialect): + name = "nohook" + driver = "nohook" + + d = NoHookDialect(dbapi=Mock(__version__="1.2.3")) + with expect_raises_message( + NotImplementedError, + "Dialect nohook\\+nohook does not implement " + "retrieve_dbapi_version\\(\\)", + ): + d.dbapi_version + + def test_version_not_determinable(self): + # a DBAPI which publishes no version at all + dbapi = Mock(spec=["paramstyle"], __name__="mydbapi") + dbapi.paramstyle = "qmark" + d = self._dialect_cls()(dbapi=dbapi) + with expect_raises_message( + exc.NoDBAPILoaded, + "Dialect mydialect\\+mydriver could not determine a version " + "for its DBAPI module 'mydbapi'", + ): + d.dbapi_version + + def test_version_not_determinable_wrapper_dbapi(self): + """asyncio dialects have a wrapper object rather than a module, + which has no __name__ to name in the message""" + + dbapi = Mock(spec=["paramstyle"]) + dbapi.paramstyle = "qmark" + d = self._dialect_cls()(dbapi=dbapi) + with expect_raises_message( + exc.NoDBAPILoaded, + "Dialect mydialect\\+mydriver could not determine a version " + "for its DBAPI module 'mydriver'", + ): + d.dbapi_version + + @testing.combinations("no_dbapi", "no_version", argnames="scenario") + def test_or_none_helper(self, scenario): + """_dbapi_version_or_none returns None rather than raising""" + + if scenario == "no_dbapi": + dbapi = None + else: + dbapi = Mock(spec=["paramstyle"], __name__="mydbapi") + dbapi.paramstyle = "qmark" + + d = self._dialect_cls()(dbapi=dbapi) + is_(d._dbapi_version_or_none, None) + + def test_or_none_helper_present(self): + d = self._dialect_cls()(dbapi=Mock(__version__="1.2.3")) + eq_(d._dbapi_version_or_none, (1, 2, 3)) + + +class MinimumDBAPIVersionTest(fixtures.TestBase): + """test :attr:`.Dialect.minimum_dbapi_version`.""" + + def _dialect_cls(self): + class MyDialect(DefaultDialect): + name = "mydialect" + driver = "mydriver" + + minimum_dbapi_version = tsa.util.VersionInfo((2, 5)) + + def retrieve_dbapi_version(self, dbapi): + return tsa.util.parse_version_string( + getattr(dbapi, "__version__", None) + ) + + return MyDialect + + @testing.combinations("2.5", "2.5.1", "3.0", argnames="version") + def test_version_ok(self, version): + d = self._dialect_cls()( + dbapi=Mock(__name__="mydbapi", __version__=version) + ) + eq_(d.dbapi_version, tsa.util.parse_version_string(version)) + + @testing.combinations("2.4.9", "2.5rc1", "1.0", argnames="version") + def test_version_too_low(self, version): + with expect_raises_message( + exc.InvalidRequestError, + "Dialect mydialect\\+mydriver requires version 2.5 or greater " + f"of the mydbapi DBAPI; version {re.escape(version)} is " + "installed", + ): + self._dialect_cls()( + dbapi=Mock(__name__="mydbapi", __version__=version) + ) + + @testing.combinations("no_dbapi", "no_version", argnames="scenario") + def test_no_version_no_check(self, scenario): + """a version which can't be determined skips the check""" + + if scenario == "no_dbapi": + dbapi = None + else: + dbapi = Mock(spec=["paramstyle"], __name__="mydbapi") + dbapi.paramstyle = "qmark" + + d = self._dialect_cls()(dbapi=dbapi) + is_(d._dbapi_version_or_none, None) + + def test_no_minimum_no_check(self): + """a dialect with no minimum doesn't consult the version at all""" + + class NoHookDialect(DefaultDialect): + name = "nohook" + driver = "nohook" + + # retrieve_dbapi_version() would raise NotImplementedError + NoHookDialect(dbapi=Mock(__name__="mydbapi", __version__="1.0")) + + class CreateEngineTest(fixtures.TestBase): """test that create_engine arguments of different types get propagated properly""" diff --git a/test/requirements.py b/test/requirements.py index df78980072..239432d928 100644 --- a/test/requirements.py +++ b/test/requirements.py @@ -1843,7 +1843,7 @@ class DefaultRequirements(SuiteRequirements): config, "mssql+aioodbc" ): return False - if config.db.dialect._dbapi_version() < (4, 0, 19): + if config.db.dialect.dbapi_version < (4, 0, 19): return False with config.db.connect() as conn: driver_connection = conn.connection.driver_connection -- 2.47.3