]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Standardize DBAPI version access across dialects main
authorMike Bayer <mike_mp@zzzcomputing.com>
Thu, 6 Aug 2026 16:37:46 +0000 (12:37 -0400)
committerMichael Bayer <mike_mp@zzzcomputing.com>
Thu, 6 Aug 2026 19:23:09 +0000 (19:23 +0000)
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

34 files changed:
doc/build/changelog/unreleased_21/dbapi_version.rst [new file with mode: 0644]
lib/sqlalchemy/connectors/pyodbc.py
lib/sqlalchemy/dialects/mssql/mssqlpython.py
lib/sqlalchemy/dialects/mssql/pymssql.py
lib/sqlalchemy/dialects/mssql/pyodbc.py
lib/sqlalchemy/dialects/mysql/aiomysql.py
lib/sqlalchemy/dialects/mysql/asyncmy.py
lib/sqlalchemy/dialects/mysql/mariadbconnector.py
lib/sqlalchemy/dialects/mysql/mysqlconnector.py
lib/sqlalchemy/dialects/mysql/mysqldb.py
lib/sqlalchemy/dialects/mysql/pymysql.py
lib/sqlalchemy/dialects/oracle/cx_oracle.py
lib/sqlalchemy/dialects/oracle/oracledb.py
lib/sqlalchemy/dialects/postgresql/_psycopg_common.py
lib/sqlalchemy/dialects/postgresql/asyncpg.py
lib/sqlalchemy/dialects/postgresql/pg8000.py
lib/sqlalchemy/dialects/postgresql/psycopg.py
lib/sqlalchemy/dialects/postgresql/psycopg2.py
lib/sqlalchemy/dialects/sqlite/aiosqlite.py
lib/sqlalchemy/dialects/sqlite/pysqlcipher.py
lib/sqlalchemy/dialects/sqlite/pysqlite.py
lib/sqlalchemy/engine/default.py
lib/sqlalchemy/engine/interfaces.py
lib/sqlalchemy/exc.py
lib/sqlalchemy/testing/exclusions.py
lib/sqlalchemy/util/__init__.py
lib/sqlalchemy/util/langhelpers.py
test/base/test_except.py
test/base/test_utils.py
test/dialect/mysql/test_dialect.py
test/dialect/oracle/test_dialect.py
test/dialect/test_pyodbc.py [deleted file]
test/engine/test_parseconnect.py
test/requirements.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 (file)
index 0000000..c76fd4c
--- /dev/null
@@ -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``.
index 84a3f043118b0dd16e389a81bfd9d73acfcf35e0..d41b9811652fe2bd2f47ee5bdc7078b215dcc69d 100644 (file)
@@ -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.
index 9c388a2af3e4f48f53697e9b30b0f578e2f6b510..ff4dd3589f5960ec42fbb3d85d46a62f97450fa2 100644 (file)
@@ -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()
index 6c6c3a2e7457a2e781b786310e0a201dd0d706ff..7347b958152a9ac0101fe8b650deb3cd0aa28ad2 100644 (file)
@@ -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)
index f1bfbb5679407cfad277e4bf64b31968d4fa4dda..ff37fc2a47baaa1e73111aa23604d448d8682cf3 100644 (file)
@@ -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:
index 67fca8ee2f87aef9824e7e9e82084d88dc2047e6..099a7de18c9b164280029dd295e7a5d20a343020 100644 (file)
@@ -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()
 
index c2b731027376b0e0de3b37a3e34e48d09559bcd8..7544d225c0fda521d76bbeb369dc270ea2e8466a 100644 (file)
@@ -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()
 
index 2c17b7b5f93a90e08ce7200c6f64437800468134..0d4fd7311028b45b4231bae2c2e1cfabc6188899 100644 (file)
@@ -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:
index 07a657137d4f3684c2b3d67d7684922fdc7a5d9c..4c62aa49ff5c5119fb6e2a657b899170f77a73a1 100644 (file)
@@ -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]
index e5dbf2d07a8fb9f5e44ca0e7aa5ec013a5af8e9b..3e896f1973009de2a235983c03fd4569e6fd0ec9 100644 (file)
@@ -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:
index ad44871ba77e9ec32e6b08fd54a505f50e202f84..6e41352370e7304d812261a1ab5e46e15d026160 100644 (file)
@@ -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:
index e48da44fad499ab82bc5228c22ccedf249e9bcec..aeda59ef6a518eb60a18d2749175a9d1713cc638 100644 (file)
@@ -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):
index cf1597690688c5a4b923b6f590620fffdabd912a..52e2abaf4a0e858ae1363eadbe05c98c2f4f390a 100644 (file)
@@ -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
index 3a3f823cca11c67406c35671a74bfad8712739d1..f2ea4d262cd2f5c9cd3ae67d7266dc01d0917f4f 100644 (file)
@@ -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,
index 4d3a3ba045cfc262f2b47d92e71fd357bbdd003a..24b04a8ed7bb5ed8000a3634dfbc596913ef798f 100644 (file)
@@ -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):
index fc1e65e66de77121f12d2f63ed66dd8b717efa4d..d107e4f1bfe3238a1139f06873317b97a3740475 100644 (file)
@@ -97,7 +97,6 @@ of the :ref:`psycopg2 <psycopg2_isolation_level>` 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):
index 46d7bbf5b5d8e42bf0cb5a85d1c43e24f1d27deb..fd147e02268e10ea7a3a59b42867471b8baa18d3 100644 (file)
@@ -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(
index 6035c79ed99cd9a2e880dfdf525e3ec8ca390394..ee9d78e6233d22a4202658e7a61f1ad6b628bcfc 100644 (file)
@@ -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)
index 7b9657bbeee78a47baf178af19a8565649bc98aa..2a5f71217343b1c223b1d50e6ddf4693ffd4721d 100644 (file)
@@ -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):
index f294a66ee251ca7b209cb765d0e90470941e3b3e..e30be35429d74ef65285c25cc433588116203d7c 100644 (file)
@@ -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
index 0d22182bf88aed92f2d3f9b2b3390c0c0b467e10..54fe3b1701d43de71ce5340658ea8e3720ef08a5 100644 (file)
@@ -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,
index e129086807ee9ad4954d1c19cf3ea56657d50fb3..3903b935d10a3d8ba6333400358a846f8743d4c4 100644 (file)
@@ -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
index 0109e3b4f5eba7c9dd9ca809d2c5f4f86c571dd1..50dc10e9af2ce00cdb91b429dd89fd1dcf0b968d 100644 (file)
@@ -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
index ed400aa9a7e69127572223113c869666b363cf29..c91930e7393cdff2180846a59a747358cb0165b4 100644 (file)
@@ -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.
index d1bdd2e304c5d2513c3dcb83cd129bdccb0bee53..884bb3e6bc42b7ccd9f625dfb504493ba24e5b2f 100644 (file)
@@ -273,6 +273,30 @@ class BooleanPredicate(Predicate):
 
 
 class SpecPredicate(Predicate):
+    """Predicate against a database and optional version.
+
+    The ``db`` string is of the form ``<backend>``, ``<backend>+<driver>``
+    or ``+<driver>``, 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]
             )
index 183a6a1f20bc1bb82ee0b21fb9ecfcfa58e26cc9..2572937a4f475a3c73557b7faf45a95e125511a0 100644 (file)
@@ -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
index 20c497906473f7198c5141ba2c75804690ab0717..eb47ed304283bd8b944f50887741d348abf0b266 100644 (file)
@@ -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<release>\d+(?:\.\d+)*)
+    (?:                                           # pre-release
+        [-_.]?
+        (?P<pre_l>alpha|beta|preview|pre|rc|a|b|c)
+        [-_.]?
+        (?P<pre_n>\d+)?
+    )?
+    (?:                                           # post-release
+        [-_.]?
+        (?P<post_l>post|rev|r)
+        [-_.]?
+        (?P<post_n>\d+)?
+    )?
+    (?:                                           # developmental release
+        [-_.]?
+        (?P<dev_l>dev)
+        [-_.]?
+        (?P<dev_n>\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()
 
index 44f7931a118869a37a80c508699f0dec853e2a9c..fc4ef4ea64c7f13d31854fb48b4556295151f9ff 100644 (file)
@@ -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,
index 2a45059384cf54a88715cfa29fa197a1168c87b3..72fe768cde808f15360d4742ea95df9800d19637 100644 (file)
@@ -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_(
index b528f485714a02da53c6d9e3deba274cdfaec55c..e69af8b54efe9243beb80b0ec8228dbc1b6793c9 100644 (file)
@@ -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()()
index 6ea6fe0c3c0c3c33b82aaf971eef70a7ff5153dd..dba00895ce7bb843accf1124c2994e834b3c347c 100644 (file)
@@ -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 (file)
index 80f1e46..0000000
+++ /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)
index 1673916c61812048bd8c430315bae2037307d1fa..254ac1ff1212375a6bb7d3c5c18a3cf14858edf1 100644 (file)
@@ -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"""
index df78980072ec785646601c5c5a252c9e6fb5d10d..239432d928c148777ab611ebfabd4934659543af 100644 (file)
@@ -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