]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
mssql-python
authorMike Bayer <mike_mp@zzzcomputing.com>
Wed, 17 Sep 2025 19:31:11 +0000 (15:31 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Fri, 6 Mar 2026 15:59:03 +0000 (10:59 -0500)
Added support for the ``mssql-python`` driver, Microsoft's official Python
driver for SQL Server.

Fixes: #12869
Change-Id: I9ce0fef1dd1105c20833cc6a46f24ac9580c4b39

21 files changed:
doc/build/changelog/migration_21.rst
doc/build/changelog/unreleased_21/12869.rst [new file with mode: 0644]
doc/build/dialects/mssql.rst
lib/sqlalchemy/dialects/mssql/__init__.py
lib/sqlalchemy/dialects/mssql/aioodbc.py
lib/sqlalchemy/dialects/mssql/base.py
lib/sqlalchemy/dialects/mssql/mssqlpython.py [new file with mode: 0644]
lib/sqlalchemy/dialects/mssql/provision.py
lib/sqlalchemy/dialects/mssql/pyodbc.py
lib/sqlalchemy/engine/base.py
lib/sqlalchemy/engine/default.py
lib/sqlalchemy/testing/provision.py
noxfile.py
pyproject.toml
setup.cfg
test/dialect/mssql/test_query.py
test/dialect/mssql/test_types.py
test/engine/test_execute.py
test/engine/test_reconnect.py
test/requirements.py
tox.ini

index 193ef56690a2e82795ccee5904207a2ed477e7da..c3094618c7231c69dec2850b3fc0b719481bb761 100644 (file)
@@ -1675,6 +1675,41 @@ required if using the connection string directly with ``pyodbc.connect()``).
 
 :ticket:`11250`
 
+.. _change_12869:
+
+Support for mssql-python driver
+--------------------------------
+
+SQLAlchemy 2.1 adds support for the ``mssql-python`` driver, Microsoft's
+official Python driver for SQL Server. This driver represents a modern
+alternative to the widely-used ``pyodbc`` driver, with native support for
+several SQL Server-specific features.
+
+The ``mssql-python`` driver can be used by specifying ``mssql+mssqlpython``
+in the connection URL::
+
+    from sqlalchemy import create_engine
+
+    # Basic connection
+    engine = create_engine("mssql+mssqlpython://user:password@hostname/database")
+
+    # With Windows Authentication
+    engine = create_engine(
+        "mssql+mssqlpython://hostname/database?authentication=ActiveDirectoryIntegrated"
+    )
+
+The ``mssql-python`` driver is available from PyPI:
+
+.. sourcecode:: text
+
+    pip install mssql-python
+
+.. seealso::
+
+    :ref:`mssql_python` - Documentation for the mssql-python dialect
+
+:ticket:`12869`
+
 
 Oracle Database
 ===============
diff --git a/doc/build/changelog/unreleased_21/12869.rst b/doc/build/changelog/unreleased_21/12869.rst
new file mode 100644 (file)
index 0000000..173ec53
--- /dev/null
@@ -0,0 +1,11 @@
+.. change::
+    :tags: feature, mssql
+    :tickets: 12869
+
+    Added support for the ``mssql-python`` driver, Microsoft's official Python
+    driver for SQL Server.
+
+    .. seealso::
+
+        :ref:`change_12869`
+
index b4ea496905e72a294ec44de5eb26a97cb4d7d7b6..5d7d35395e49b478e257ea4519b4f9fcfb0d1b09 100644 (file)
@@ -161,6 +161,12 @@ PyODBC
 ------
 .. automodule:: sqlalchemy.dialects.mssql.pyodbc
 
+.. _mssql_python:
+
+mssql-python
+------------
+.. automodule:: sqlalchemy.dialects.mssql.mssqlpython
+
 pymssql
 -------
 .. automodule:: sqlalchemy.dialects.mssql.pymssql
index 68d1d7c744e4cd04446d81c14efe873503d740e0..3ad714921c84622e4e23917d9eba8463c11197da 100644 (file)
@@ -8,6 +8,7 @@
 
 from . import aioodbc  # noqa
 from . import base  # noqa
+from . import mssqlpython  # noqa
 from . import pymssql  # noqa
 from . import pyodbc  # noqa
 from .base import BIGINT
index e4d5b2d2216234846a54e62444e830644033c24c..1139f37f798fd1210a32ecded47487c6281bb4a1 100644 (file)
@@ -42,12 +42,12 @@ styles are otherwise equivalent to those documented in the pyodbc section::
 
 from __future__ import annotations
 
+from .base import MSExecutionContext
 from .pyodbc import MSDialect_pyodbc
-from .pyodbc import MSExecutionContext_pyodbc
 from ...connectors.aioodbc import aiodbcConnector
 
 
-class MSExecutionContext_aioodbc(MSExecutionContext_pyodbc):
+class MSExecutionContext_aioodbc(MSExecutionContext):
     def create_server_side_cursor(self):
         return self._dbapi_connection.cursor(server_side=True)
 
index d627127db0c203a1e58b629487bdaaa621e9df62..eae3fe7058ae74e73dc89aa8080abac9ff7802d4 100644 (file)
@@ -1838,6 +1838,7 @@ class MSTypeCompiler(compiler.GenericTypeCompiler):
 
 
 class MSExecutionContext(default.DefaultExecutionContext):
+    _embedded_scope_identity = False
     _enable_identity_insert = False
     _select_lastrowid = False
     _lastrowid = None
@@ -1900,15 +1901,46 @@ class MSExecutionContext(default.DefaultExecutionContext):
                     self,
                 )
 
+            # don't embed the scope_identity select into an
+            # "INSERT .. DEFAULT VALUES"
+            if (
+                self._select_lastrowid
+                and self.dialect.scope_identity_must_be_embedded
+                and self.dialect.use_scope_identity
+                and len(self.parameters[0])
+            ):
+                self._embedded_scope_identity = True
+
+                self.statement += "; select scope_identity()"
+
     def post_exec(self):
-        """Disable IDENTITY_INSERT if enabled."""
 
         conn = self.root_connection
 
         if self.isinsert or self.isupdate or self.isdelete:
             self._rowcount = self.cursor.rowcount
 
-        if self._select_lastrowid:
+        # handle INSERT with embedded SELECT SCOPE_IDENTITY() call
+        if self._embedded_scope_identity:
+            # Fetch the last inserted id from the manipulated statement
+            # We may have to skip over a number of result sets with
+            # no data (due to triggers, etc.) so run up to three times
+
+            row = None
+            for _ in range(3):
+                if self.cursor.description:
+                    rows = self.cursor.fetchall()
+                    if rows:
+                        row = rows[0]
+                        break
+                else:
+                    self.cursor.nextset()
+
+            self._lastrowid = int(row[0]) if row else None
+
+            self.cursor_fetch_strategy = _cursor._NO_CURSOR_DML
+
+        elif self._select_lastrowid:
             if self.dialect.use_scope_identity:
                 conn._cursor_execute(
                     self.cursor,
@@ -1939,6 +1971,7 @@ class MSExecutionContext(default.DefaultExecutionContext):
             )
 
         if self._enable_identity_insert:
+            # Disable IDENTITY_INSERT if enabled.
             if TYPE_CHECKING:
                 assert is_sql_compiler(self.compiled)
                 assert isinstance(self.compiled.compile_state, DMLState)
@@ -3026,6 +3059,7 @@ class MSDialect(default.DefaultDialect):
     supports_default_values = True
     supports_empty_insert = False
     favor_returning_over_lastrowid = True
+    scope_identity_must_be_embedded = False
 
     returns_native_bytes = True
 
@@ -3259,6 +3293,7 @@ class MSDialect(default.DefaultDialect):
                 'attempting to query the "{}" view.'.format(err, view_name)
             ) from err
         else:
+
             row = cursor.fetchone()
             return row[0].upper()
         finally:
diff --git a/lib/sqlalchemy/dialects/mssql/mssqlpython.py b/lib/sqlalchemy/dialects/mssql/mssqlpython.py
new file mode 100644 (file)
index 0000000..80a89fd
--- /dev/null
@@ -0,0 +1,220 @@
+# dialects/mssql/mssqlpython.py
+# Copyright (C) 2005-2026 the SQLAlchemy authors and contributors
+# <see AUTHORS file>
+#
+# This module is part of SQLAlchemy and is released under
+# the MIT License: https://www.opensource.org/licenses/mit-license.php
+# mypy: ignore-errors
+
+"""
+.. dialect:: mssql+mssqlpython
+    :name: mssqlpython
+    :dbapi: mssql-python
+    :connectstring: mssql+mssqlpython://<username>:<password>@<host>:<port>/<dbname>
+    :url: https://github.com/microsoft/mssql-python
+
+mssql-python is a driver for Microsoft SQL Server produced by Microsoft.
+
+.. versionadded:: 2.1.0b2
+
+
+The driver is generally similar to pyodbc in most aspects as it is based
+on the same ODBC framework.
+
+Connection Strings
+------------------
+
+Examples of connecting with the mssql-python driver::
+
+    from sqlalchemy import create_engine
+
+    # Basic connection
+    engine = create_engine(
+        "mssql+mssqlpython://user:password@hostname/database"
+    )
+
+    # With Windows Authentication
+    engine = create_engine(
+        "mssql+mssqlpython://hostname/database?authentication=ActiveDirectoryIntegrated"
+    )
+
+"""  # noqa
+
+from __future__ import annotations
+
+import re
+from typing import Any
+from typing import Dict
+from typing import List
+from typing import Optional
+from typing import TYPE_CHECKING
+from typing import Union
+
+from .base import MSDialect
+from .pyodbc import _ms_numeric_pyodbc
+from ... import util
+from ...sql import sqltypes
+
+if TYPE_CHECKING:
+    from ... import pool
+    from ...engine import interfaces
+    from ...engine.interfaces import ConnectArgsType
+    from ...engine.interfaces import DBAPIModule
+    from ...engine.interfaces import IsolationLevel
+    from ...engine.interfaces import URL
+
+
+class _MSNumeric_mssqlpython(_ms_numeric_pyodbc, sqltypes.Numeric):
+    pass
+
+
+class _MSFloat_mssqlpython(_ms_numeric_pyodbc, sqltypes.Float):
+    pass
+
+
+class MSDialect_mssqlpython(MSDialect):
+    driver = "mssqlpython"
+    supports_statement_cache = True
+
+    supports_sane_rowcount_returning = True
+    supports_sane_multi_rowcount = True
+    supports_native_uuid = True
+    scope_identity_must_be_embedded = True
+
+    supports_native_decimal = True
+
+    # used by pyodbc _ms_numeric_pyodbc class
+    _need_decimal_fix = True
+
+    colspecs = util.update_copy(
+        MSDialect.colspecs,
+        {
+            sqltypes.Numeric: _MSNumeric_mssqlpython,
+            sqltypes.Float: _MSFloat_mssqlpython,
+        },
+    )
+
+    def __init__(self, enable_pooling=False, **kw):
+        super().__init__(**kw)
+        if not enable_pooling and self.dbapi is not None:
+            self.loaded_dbapi.pooling(enabled=False)
+
+    @classmethod
+    def import_dbapi(cls) -> DBAPIModule:
+        return __import__("mssql_python")
+
+    def create_connect_args(self, url: URL) -> ConnectArgsType:
+        opts = url.translate_connect_args(username="user")
+        opts.update(url.query)
+
+        keys = opts
+
+        query = url.query
+
+        connect_args: Dict[str, Any] = {}
+        connectors: List[str]
+
+        def check_quote(token: str) -> str:
+            if ";" in str(token) or str(token).startswith("{"):
+                token = "{%s}" % token.replace("}", "}}")
+            return token
+
+        keys = {k: check_quote(v) for k, v in keys.items()}
+
+        port = ""
+        if "port" in keys and "port" not in query:
+            port = ",%d" % int(keys.pop("port"))
+
+        connectors = []
+
+        connectors.extend(
+            [
+                "Server=%s%s" % (keys.pop("host", ""), port),
+                "Database=%s" % keys.pop("database", ""),
+            ]
+        )
+
+        user = keys.pop("user", None)
+        if user:
+            connectors.append("UID=%s" % user)
+            pwd = keys.pop("password", "")
+            if pwd:
+                connectors.append("PWD=%s" % pwd)
+        else:
+            authentication = keys.pop("authentication", None)
+            if authentication:
+                connectors.append("Authentication=%s" % authentication)
+
+        connectors.extend(["%s=%s" % (k, v) for k, v in keys.items()])
+
+        return ((";".join(connectors),), connect_args)
+
+    def is_disconnect(
+        self,
+        e: Exception,
+        connection: Optional[
+            Union[pool.PoolProxiedConnection, interfaces.DBAPIConnection]
+        ],
+        cursor: Optional[interfaces.DBAPICursor],
+    ) -> bool:
+        if isinstance(e, self.loaded_dbapi.ProgrammingError):
+            return (
+                "The cursor's connection has been closed." in str(e)
+                or "Attempt to use a closed connection." in str(e)
+                or "Driver Error: Operation cannot be performed" in str(e)
+            )
+        elif isinstance(e, self.loaded_dbapi.InterfaceError):
+            return bool(re.search(r"Cannot .* on closed connection", str(e)))
+        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 _get_server_version_info(self, connection):
+        vers = connection.exec_driver_sql("select @@version").scalar()
+        m = re.match(r"Microsoft .*? - (\d+)\.(\d+)\.(\d+)\.(\d+)", vers)
+        if m:
+            return tuple(int(x) for x in m.group(1, 2, 3, 4))
+        else:
+            return None
+
+    def get_isolation_level_values(
+        self, dbapi_connection: interfaces.DBAPIConnection
+    ) -> List[IsolationLevel]:
+        return [
+            *super().get_isolation_level_values(dbapi_connection),
+            "AUTOCOMMIT",
+        ]
+
+    def set_isolation_level(
+        self,
+        dbapi_connection: interfaces.DBAPIConnection,
+        level: IsolationLevel,
+    ) -> None:
+        if level == "AUTOCOMMIT":
+            dbapi_connection.autocommit = True
+        else:
+            dbapi_connection.autocommit = False
+            super().set_isolation_level(dbapi_connection, level)
+
+    def detect_autocommit_setting(
+        self, dbapi_conn: interfaces.DBAPIConnection
+    ) -> bool:
+        return bool(dbapi_conn.autocommit)
+
+
+dialect = MSDialect_mssqlpython
index 5b53fd987710d3c749ca79420e17a5b020533545..823a13c95d40313ed4b64197d255f0f6150b01fc 100644 (file)
@@ -16,6 +16,7 @@ from ...schema import ForeignKeyConstraint
 from ...schema import MetaData
 from ...schema import Table
 from ...testing.provision import create_db
+from ...testing.provision import dbapi_error
 from ...testing.provision import drop_all_schema_objects_pre_tables
 from ...testing.provision import drop_db
 from ...testing.provision import generate_driver_url
@@ -39,8 +40,10 @@ def generate_driver_url(url, driver, query_str):
 
     new_url = url.set(drivername="%s+%s" % (backend, driver))
 
-    if driver not in ("pyodbc", "aioodbc"):
+    if driver == "pymssql" and url.get_driver_name() != "pymssql":
         new_url = new_url.set(query="")
+    elif driver == "mssqlpython" and url.get_driver_name() != "mssqlpython":
+        new_url = new_url.set(query={"Encrypt": "No"})
 
     if driver == "aioodbc":
         new_url = new_url.update_query_dict({"MARS_Connection": "Yes"})
@@ -183,3 +186,11 @@ def normalize_sequence(cfg, sequence):
     if sequence.start is None:
         sequence.start = 1
     return sequence
+
+
+@dbapi_error.for_db("mssql")
+def dbapi_error(cfg, cls, message):
+    if cfg.db.driver == "mssqlpython":
+        return cls(message, "placeholder for mssqlpython")
+    else:
+        return cls(message)
index ee1b058f5b083569cb83071219a7a07585203bc1..07b9eef60be93676eae58c5fc80375581818ca32 100644 (file)
@@ -369,7 +369,6 @@ from .base import _MSUnicodeText
 from .base import BINARY
 from .base import DATETIMEOFFSET
 from .base import MSDialect
-from .base import MSExecutionContext
 from .base import VARBINARY
 from .json import JSON as _MSJson
 from .json import JSONIndexType as _MSJsonIndexType
@@ -378,7 +377,6 @@ from ... import exc
 from ... import types as sqltypes
 from ... import util
 from ...connectors.pyodbc import PyODBCConnector
-from ...engine import cursor as _cursor
 
 
 class _ms_numeric_pyodbc:
@@ -560,73 +558,15 @@ class _JSONPathType_pyodbc(_MSJsonPathType):
         return dbapi.SQL_WVARCHAR
 
 
-class MSExecutionContext_pyodbc(MSExecutionContext):
-    _embedded_scope_identity = False
-
-    def pre_exec(self):
-        """where appropriate, issue "select scope_identity()" in the same
-        statement.
-
-        Background on why "scope_identity()" is preferable to "@@identity":
-        https://msdn.microsoft.com/en-us/library/ms190315.aspx
-
-        Background on why we attempt to embed "scope_identity()" into the same
-        statement as the INSERT:
-        https://code.google.com/p/pyodbc/wiki/FAQs#How_do_I_retrieve_autogenerated/identity_values?
-
-        """
-
-        super().pre_exec()
-
-        # don't embed the scope_identity select into an
-        # "INSERT .. DEFAULT VALUES"
-        if (
-            self._select_lastrowid
-            and self.dialect.use_scope_identity
-            and len(self.parameters[0])
-        ):
-            self._embedded_scope_identity = True
-
-            self.statement += "; select scope_identity()"
-
-    def post_exec(self):
-        if self._embedded_scope_identity:
-            # Fetch the last inserted id from the manipulated statement
-            # We may have to skip over a number of result sets with
-            # no data (due to triggers, etc.)
-            while True:
-                try:
-                    # fetchall() ensures the cursor is consumed
-                    # without closing it (FreeTDS particularly)
-                    rows = self.cursor.fetchall()
-                except self.dialect.dbapi.Error:
-                    # no way around this - nextset() consumes the previous set
-                    # so we need to just keep flipping
-                    self.cursor.nextset()
-                else:
-                    if not rows:
-                        # async adapter drivers just return None here
-                        self.cursor.nextset()
-                        continue
-                    row = rows[0]
-                    break
-
-            self._lastrowid = int(row[0])
-
-            self.cursor_fetch_strategy = _cursor._NO_CURSOR_DML
-        else:
-            super().post_exec()
-
-
 class MSDialect_pyodbc(PyODBCConnector, MSDialect):
     supports_statement_cache = True
 
+    scope_identity_must_be_embedded = True
+
     # note this parameter is no longer used by the ORM or default dialect
     # see #9414
     supports_sane_rowcount_returning = False
 
-    execution_ctx_cls = MSExecutionContext_pyodbc
-
     colspecs = util.update_copy(
         MSDialect.colspecs,
         {
index d1015b4a35df6a5cb15090e868bbb4207e052633..9aaf02f4a2dfbfdf8390e14e65c449594037fc42 100644 (file)
@@ -2095,7 +2095,6 @@ class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"]):
                         sub_params,
                         context,
                     )
-
             except BaseException as e:
                 self._handle_dbapi_exception(
                     e,
index 22253d6a612fde0ad0828d3cdd1f551bf5d8d2f1..d31f8c4f3984e2bf6b0c26a2f261900d9c8ad67c 100644 (file)
@@ -893,6 +893,7 @@ class DefaultDialect(Dialect):
                         Dict[Tuple[Any, ...], Any],
                         Dict[Any, Any],
                     ]
+
                     if composite_sentinel:
                         rows_by_sentinel = {
                             tuple(
index 97d64ceb1eaf71636b46e757689713c0f16c2d62..0f53db64631bf94ee9739b176d0dc7c524408186 100644 (file)
@@ -600,3 +600,14 @@ def delete_from_all_tables(connection, cfg, metadata):
                 connection.execute(table.delete())
         else:
             connection.execute(table.delete())
+
+
+@register.init
+def dbapi_error(cfg, cls, message):
+    """create a DBAPI error
+
+    :param cls: the DBAPI class, like ``dialect.dbapi.OperationalError``
+    :param message: message for the error
+
+    """
+    return cls(message)
index e9d0c1fa93e077af5efebc2652c9639f929e24b1..6678dfa730fec22f0a8c93e3ec187bdbe8b4827b 100644 (file)
@@ -52,7 +52,10 @@ DB_CLI_NAMES = {
         "nogreenlet": {"cx_oracle", "oracledb"},
         "greenlet": {"oracledb_async"},
     },
-    "mssql": {"nogreenlet": {"pyodbc", "pymssql"}, "greenlet": {"aioodbc"}},
+    "mssql": {
+        "nogreenlet": {"pyodbc", "pymssql", "mssqlpython"},
+        "greenlet": {"aioodbc"},
+    },
 }
 
 
index 4676b346b85ba7ea001702c234fdc083c8c9fba6..022cc67c8f75f0b2bca71e2a02904b886ba4f20f 100644 (file)
@@ -51,6 +51,7 @@ mypy = [
 mssql = ["pyodbc"]
 mssql-pymssql = ["pymssql"]
 mssql-pyodbc = ["pyodbc"]
+mssql-python = ["mssql-python>=0.13.1"]
 mysql = ["mysqlclient>=1.4.0"]
 mysql-connector = ["mysql-connector-python"]
 mariadb-connector = ["mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10"]
@@ -163,6 +164,7 @@ tests-oracle = [
 tests-mssql = [
     "sqlalchemy[mssql]",
     "sqlalchemy[mssql-pymssql]",
+    "sqlalchemy[mssql-python]",
 ]
 
 tests-mssql-asyncio = [
index 2700e67a98c278e9323a238c3ab9e0c958d7a85c..97dff26d6b8bd47e3d6bd5ec4481b0217390aae8 100644 (file)
--- a/setup.cfg
+++ b/setup.cfg
@@ -46,6 +46,7 @@ mysql_connector = mariadb+mysqlconnector://scott:tiger@127.0.0.1:3306/test
 mssql = mssql+pyodbc://scott:tiger^5HHH@mssql2022:1433/test?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Encrypt=Optional
 mssql_async = mssql+aioodbc://scott:tiger^5HHH@mssql2022:1433/test?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Encrypt=Optional
 pymssql = mssql+pymssql://scott:tiger^5HHH@mssql2022:1433/test
+mssql_python = mssql+mssqlpython://scott:tiger^5HHH@mssql2022:1433/test?Encrypt=No
 docker_mssql = mssql+pyodbc://scott:tiger^5HHH@127.0.0.1:1433/test?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Encrypt=Optional
 oracle = oracle+oracledb://scott:tiger@oracle23c/freepdb1
 cxoracle = oracle+cx_oracle://scott:tiger@oracle23c/freepdb1
index 33f648b82a0f0765bdcb8a592595432756bfaf3b..ce642b00ef67e8f1e247532da0f0e04148ff9bb3 100644 (file)
@@ -184,6 +184,11 @@ class QueryTest(testing.AssertsExecutionResults, fixtures.TestBase):
         older SCOPE_IDENTITY() call, which still works for this scenario.
         To enable the workaround, the Table must be instantiated
         with the init parameter 'implicit_returning = False'.
+
+        Note: this **wont work** with SELECT @@identity.  it has to be
+        SCOPE_IDENTITY().   for pyodbc, this means the function has to be
+        embedded on the same line as the INSERT.
+
         """
 
         # TODO: this same test needs to be tried in a multithreaded context
@@ -305,7 +310,7 @@ class QueryTest(testing.AssertsExecutionResults, fixtures.TestBase):
             ),
         )
 
-    @testing.only_on("mssql+pyodbc")
+    @testing.only_on(["mssql+pyodbc", "mssql+mssqlpython"])
     @testing.provide_metadata
     def test_embedded_scope_identity(self):
         engine = engines.testing_engine(options={"use_scope_identity": True})
@@ -323,11 +328,20 @@ class QueryTest(testing.AssertsExecutionResults, fixtures.TestBase):
             with engine.begin() as conn:
                 conn.execute(t1.insert(), {"data": "somedata"})
 
-        # pyodbc-specific system
+        if testing.against("mssql+pyodbc"):
+            paramstyle = "?"
+            params = ("somedata",)
+        elif testing.against("mssql+mssqlpython"):
+            paramstyle = "%(data)s"
+            params = {"data": "somedata"}
+        else:
+            assert False
+
         asserter.assert_(
             CursorSQL(
-                "INSERT INTO t1 (data) VALUES (?); select scope_identity()",
-                ("somedata",),
+                f"INSERT INTO t1 (data) VALUES ({paramstyle}); "
+                "select scope_identity()",
+                params,
                 consume_statement=False,
             )
         )
index 1f4bdc85f5f792333144a101c89768c8afc7b291..0f25b3a48447a4d64c375dd2f0146f5dabf825ab 100644 (file)
@@ -32,6 +32,7 @@ from sqlalchemy import Time
 from sqlalchemy import types
 from sqlalchemy import Unicode
 from sqlalchemy import UnicodeText
+from sqlalchemy import VARCHAR
 from sqlalchemy.dialects.mssql import base as mssql
 from sqlalchemy.dialects.mssql import NTEXT
 from sqlalchemy.dialects.mssql import ROWVERSION
@@ -961,12 +962,17 @@ class TypeRoundTripTest(
             ),
             None,
             True,
+            testing.fails_if(
+                "+mssqlpython",
+                "mssql-python does not seem to report this as an error",
+            ),
         ),
         (
             "dto_param_datetime_naive",
             lambda: datetime.datetime(2007, 10, 30, 11, 2, 32, 123456),
             0,
             False,
+            testing.fails_if("+pymssql", "seems to fail on pymssql"),
         ),
         (
             "dto_param_string_one",
@@ -981,11 +987,15 @@ class TypeRoundTripTest(
             0,
             False,
         ),
-        ("dto_param_string_invalid", lambda: "this is not a date", 0, True),
+        (
+            "dto_param_string_invalid",
+            lambda: "this is not a date",
+            0,
+            True,
+        ),
         id_="iaaa",
         argnames="dto_param_value, expected_offset_hours, should_fail",
     )
-    @testing.skip_if("+pymssql", "offsets dont seem to work")
     def test_datetime_offset(
         self,
         datetimeoffset_fixture,
@@ -1271,14 +1281,7 @@ class StringTest(fixtures.TestBase, AssertsCompiledSQL):
 
 
 class StringRoundTripTest(fixtures.TestBase):
-    """tests for #8661
-
-
-    at the moment most of these are using the default setinputsizes enabled
-    behavior, with the exception of the plain executemany() calls for inserts.
-
-
-    """
+    """tests for #8661 as well as other string issues"""
 
     __only_on__ = "mssql"
     __backend__ = True
@@ -1433,6 +1436,36 @@ class StringRoundTripTest(fixtures.TestBase):
         )
         eq_(result.all(), ["some other data %d" % i for i in range(3)])
 
+    @testing.combinations(
+        (3,),
+        (9,),
+        (10,),
+        (15,),
+        argnames="size",
+    )
+    @testing.variation("fetchtype", ["all", "one"])
+    def test_cp1252_varchar(
+        self, size, fetchtype: testing.Variation, metadata, connection
+    ):
+        """test for mssql-python's issue with small varchar + cp1252 strings"""
+
+        t = Table("t", metadata, Column("data", VARCHAR(size)))
+
+        t.create(connection)
+
+        data = "réveillé réveillé"[0:size]
+        connection.execute(t.insert(), {"data": data})
+
+        if fetchtype.all:
+            result = connection.execute(select(t.c.data))
+            rows = result.all()
+            received = rows[0][0]
+        elif fetchtype.one:
+            received = connection.scalar(select(t.c.data))
+        else:
+            fetchtype.fail()
+        eq_(received, data)
+
 
 class UniqueIdentifierTest(test_types.UuidTest):
     __only_on__ = "mssql"
index 98a42f11e9f97f0996d24540178dca1f026021b5..66ef5ce189ea331df1c314cdf8b694825960e635 100644 (file)
@@ -52,6 +52,7 @@ from sqlalchemy.testing import is_false
 from sqlalchemy.testing import is_not
 from sqlalchemy.testing import is_true
 from sqlalchemy.testing import ne_
+from sqlalchemy.testing import provision
 from sqlalchemy.testing.assertions import expect_deprecated
 from sqlalchemy.testing.assertsql import CompiledSQL
 from sqlalchemy.testing.provision import normalize_sequence
@@ -794,6 +795,10 @@ class ExecuteDriverTest(fixtures.TablesTest):
         "+mysqlconnector",
         "Exception doesn't come back exactly the same from pickle",
     )
+    @testing.fails_on(
+        "+mssqlpython",
+        "Exception pickling not working due to additional parameter",
+    )
     @testing.fails_on(
         "oracle+cx_oracle",
         "cx_oracle exception seems to be having some issue with pickling",
@@ -3039,7 +3044,11 @@ class HandleErrorTest(fixtures.TestBase):
         conn = engine.connect()
 
         def boom(connection):
-            raise engine.dialect.dbapi.OperationalError("rollback failed")
+            raise provision.dbapi_error(
+                config,
+                engine.dialect.dbapi.OperationalError,
+                "rollback failed",
+            )
 
         with patch.object(conn.dialect, "do_rollback", boom):
             assert_raises_message(
@@ -3066,7 +3075,11 @@ class HandleErrorTest(fixtures.TestBase):
         conn = engine.connect()
 
         def boom(connection):
-            raise engine.dialect.dbapi.OperationalError("rollback failed")
+            raise provision.dbapi_error(
+                config,
+                engine.dialect.dbapi.OperationalError,
+                "rollback failed",
+            )
 
         @event.listens_for(conn, "begin")
         def _do_begin(conn):
@@ -3256,7 +3269,11 @@ class HandleErrorTest(fixtures.TestBase):
             with patch.object(
                 conn.dialect,
                 "get_isolation_level",
-                Mock(side_effect=ProgrammingError("random error")),
+                Mock(
+                    side_effect=provision.dbapi_error(
+                        config, ProgrammingError, "random error"
+                    )
+                ),
             ):
                 assert_raises(MySpecialException, conn.get_isolation_level)
 
@@ -3532,7 +3549,9 @@ class OnConnectTest(fixtures.TestBase):
         dialect = testing.db.dialect
         dbapi = dialect.dbapi
         assert not dialect.is_disconnect(
-            dbapi.OperationalError("test"), None, None
+            provision.dbapi_error(config, dbapi.OperationalError, "test"),
+            None,
+            None,
         )
 
     def test_dont_create_transaction_on_initialize(self):
index f747d9ee0568046c6baeac6471f590726b161209..cb04e0d3bd5b7d78eae4bb8478c5009580172e7b 100644 (file)
@@ -19,6 +19,7 @@ from sqlalchemy.testing import assert_raises
 from sqlalchemy.testing import assert_raises_message
 from sqlalchemy.testing import assert_raises_message_context_ok
 from sqlalchemy.testing import assert_warns_message
+from sqlalchemy.testing import config
 from sqlalchemy.testing import engines
 from sqlalchemy.testing import eq_
 from sqlalchemy.testing import expect_raises
@@ -29,6 +30,7 @@ from sqlalchemy.testing import is_false
 from sqlalchemy.testing import is_true
 from sqlalchemy.testing import mock
 from sqlalchemy.testing import ne_
+from sqlalchemy.testing import provision
 from sqlalchemy.testing.engines import DBAPIProxyConnection
 from sqlalchemy.testing.engines import DBAPIProxyCursor
 from sqlalchemy.testing.engines import testing_engine
@@ -1037,7 +1039,9 @@ class RealPrePingEventHandlerTest(fixtures.TestBase):
         class ExplodeCursor(DBAPIProxyCursor):
             def execute(self, stmt, parameters=None, **kw):
                 if fail and next(fail_count) < 1:
-                    raise DBAPIError("unhandled disconnect situation")
+                    raise provision.dbapi_error(
+                        config, DBAPIError, "unhandled disconnect situation"
+                    )
                 else:
                     return super().execute(stmt, parameters=parameters, **kw)
 
index 164c407d5e9ae7ca3aaace5552b7e428c294fa24..af681b3166021c0918a1e3a1891e02eefa452efc 100644 (file)
@@ -325,8 +325,13 @@ class DefaultRequirements(SuiteRequirements):
     def non_broken_binary(self):
         """target DBAPI must work fully with binary values"""
 
+        # for pymssql
         # see https://github.com/pymssql/pymssql/issues/504
-        return skip_if(["mssql+pymssql"])
+        #
+        # for mssqlpython:
+        # Streaming parameters is not yet supported. Parameter size must be
+        # less than 8192 bytes
+        return skip_if(["mssql+pymssql", "mssql+mssqlpython"])
 
     @property
     def binary_comparisons(self):
@@ -758,6 +763,17 @@ class DefaultRequirements(SuiteRequirements):
             "no FOR UPDATE NOWAIT support",
         )
 
+    @property
+    def unusual_column_name_characters(self):
+        """target database allows column names that have unusual characters
+        in them, such as dots, spaces, slashes, or percent signs.
+
+        The column names are as always in such a case quoted, however the
+        DB still needs to support those characters in the name somehow.
+
+        """
+        return exclusions.skip_if("+mssqlpython", "waiting on GH issue 464")
+
     @property
     def subqueries(self):
         """Target database must support subqueries."""
@@ -1614,7 +1630,7 @@ class DefaultRequirements(SuiteRequirements):
 
         """
 
-        return exclusions.open()
+        return fails_on("+mssqlpython")
 
     @property
     def fetch_null_from_numeric(self):
diff --git a/tox.ini b/tox.ini
index 925c60aea7f27eda904a0203078f7c99309aa8e3..5f051f295f0ab1d097769164a95cd69cf6186f27 100644 (file)
--- a/tox.ini
+++ b/tox.ini
@@ -51,6 +51,7 @@ extras=
      oracle: oracle_oracledb
      mssql: mssql
      mssql-py{38,39,310,311,312,313}: mssql_pymssql
+     mssql: mssql-python
 
 install_command=
      # TODO: I can find no way to get pip / tox / anyone to have this
@@ -156,8 +157,8 @@ setenv=
 
     # for mssql, aioodbc frequently segfaults under free-threaded builds
     mssql: MSSQL={env:TOX_MSSQL:--db mssql}
-    mssql: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc --dbdriver aioodbc --dbdriver pymssql}
-    mssql-py314: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc  --dbdriver aioodbc}
+    mssql: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc --dbdriver aioodbc --dbdriver pymssql --dbdriver mssqlpython}
+    mssql-py314: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc  --dbdriver aioodbc --dbdriver mssqlpython}
     mssql-{py313t,py314t,nogreenlet: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc --dbdriver pymssql}
 
     oracle,mssql,sqlite_file: IDENTS=--write-idents db_idents.txt