diff --git a/providers/microsoft/mssql/docs/connections/mssql.rst b/providers/microsoft/mssql/docs/connections/mssql.rst index a2990742aea93..842d9ec0674d4 100644 --- a/providers/microsoft/mssql/docs/connections/mssql.rst +++ b/providers/microsoft/mssql/docs/connections/mssql.rst @@ -74,3 +74,39 @@ If serializing with JSON: "port": 1433, "schema": "database_name" }' + +Choosing the DBAPI driver +------------------------- + +By default the hook connects with `pymssql `__. To use Microsoft's +`mssql-python `__ driver instead, install the extra: + +.. code-block:: bash + + pip install 'apache-airflow-providers-microsoft-mssql[mssql-python]' + +and set ``dbapi_driver`` to ``mssql_python`` in the connection extra: + +.. code-block:: bash + + export AIRFLOW_CONN_MSSQL_DEFAULT='{ + "conn_type": "mssql", + "login": "username", + "password": "password", + "host": "server.com", + "port": 1433, + "schema": "database_name", + "extra": {"dbapi_driver": "mssql_python", "TrustServerCertificate": "yes"} + }' + +Notes +~~~~~ + +* ``dbapi_driver`` accepts ``pymssql`` (the default) or ``mssql_python`` and is case-insensitive. +* Additional driver options, such as ``Encrypt``, ``TrustServerCertificate`` and + ``Authentication``, can be supplied through connection extras. +* The default SQLAlchemy scheme is ``mssql+mssqlpython``, which requires SQLAlchemy 2.1 + or newer. Override it using ``sqlalchemy_scheme`` in the connection extra or hook argument. +* Use ``?`` placeholders with positional parameters or ``%(name)s`` with named parameters. + Plain ``%s`` placeholders are not supported. The hook defaults to ``?`` for generated statements. +* Driver-level connection pooling is enabled by default. diff --git a/providers/microsoft/mssql/docs/index.rst b/providers/microsoft/mssql/docs/index.rst index c43dd8d3670ce..8ef2e7967436b 100644 --- a/providers/microsoft/mssql/docs/index.rst +++ b/providers/microsoft/mssql/docs/index.rst @@ -139,11 +139,12 @@ Install them when installing from PyPI. For example: pip install apache-airflow-providers-microsoft-mssql[openlineage] -=============== ======================================== -Extra Dependencies -=============== ======================================== -``openlineage`` ``apache-airflow-providers-openlineage`` -=============== ======================================== +================ ======================================== +Extra Dependencies +================ ======================================== +``openlineage`` ``apache-airflow-providers-openlineage`` +``mssql-python`` ``mssql-python>=1.15.0`` +================ ======================================== Downloading official packages ----------------------------- diff --git a/providers/microsoft/mssql/pyproject.toml b/providers/microsoft/mssql/pyproject.toml index de85518c78b8d..52faa8d023c41 100644 --- a/providers/microsoft/mssql/pyproject.toml +++ b/providers/microsoft/mssql/pyproject.toml @@ -72,6 +72,9 @@ dependencies = [ "openlineage" = [ "apache-airflow-providers-openlineage" ] +"mssql-python" = [ + "mssql-python>=1.15.0" +] [dependency-groups] dev = [ diff --git a/providers/microsoft/mssql/src/airflow/providers/microsoft/mssql/hooks/mssql.py b/providers/microsoft/mssql/src/airflow/providers/microsoft/mssql/hooks/mssql.py index 54a7920436434..74e692ee88239 100644 --- a/providers/microsoft/mssql/src/airflow/providers/microsoft/mssql/hooks/mssql.py +++ b/providers/microsoft/mssql/src/airflow/providers/microsoft/mssql/hooks/mssql.py @@ -19,15 +19,16 @@ from __future__ import annotations +from functools import cached_property +from importlib import import_module from typing import TYPE_CHECKING, Any -import pymssql -from pymssql import Connection as PymssqlConnection - from airflow.providers.common.sql.hooks.sql import DbApiHook from airflow.providers.microsoft.mssql.dialects.mssql import MsSqlDialect if TYPE_CHECKING: + from pymssql import Connection as PymssqlConnection + from airflow.providers.common.compat.sdk import Connection from airflow.providers.common.sql.dialects.dialect import Dialect from airflow.providers.openlineage.sqlparser import DatabaseInfo @@ -37,8 +38,12 @@ class MsSqlHook(DbApiHook): """ Interact with Microsoft SQL Server. + The hook uses ``pymssql`` by default. Set the ``dbapi_driver`` connection extra to ``mssql_python`` + to use Microsoft's ``mssql-python`` driver instead (requires the ``mssql-python`` extra). + :param args: passed to DBApiHook - :param sqlalchemy_scheme: Scheme sqlalchemy connection. Default is ``mssql+pymssql`` Only used for + :param sqlalchemy_scheme: Scheme sqlalchemy connection. Default is ``mssql+pymssql`` (or + ``mssql+mssqlpython`` when ``dbapi_driver`` is ``mssql_python``). Only used for ``get_sqlalchemy_engine`` and ``get_sqlalchemy_connection`` methods. :param kwargs: passed to DbApiHook """ @@ -49,6 +54,24 @@ class MsSqlHook(DbApiHook): hook_name = "Microsoft SQL Server" supports_autocommit = True DEFAULT_SQLALCHEMY_SCHEME = "mssql+pymssql" + MSSQL_PYTHON_SQLALCHEMY_SCHEME = "mssql+mssqlpython" + DBAPI_DRIVER_PYMSSQL = "pymssql" + DBAPI_DRIVER_MSSQL_PYTHON = "mssql_python" + SUPPORTED_DBAPI_DRIVERS = (DBAPI_DRIVER_PYMSSQL, DBAPI_DRIVER_MSSQL_PYTHON) + # Connection extras that are handled by the hook itself and must never reach the DBAPI driver. + _AIRFLOW_ONLY_EXTRAS = frozenset({"sqlalchemy_scheme", "dbapi_driver"}) + # mssql-python takes every other extra as a connection string keyword, so the extras that + # ``DbApiHook`` reads for itself are filtered out as well. + _MSSQL_PYTHON_IGNORED_EXTRAS = _AIRFLOW_ONLY_EXTRAS | frozenset( + { + "placeholder", + "insert_statement_format", + "replace_statement_format", + "escape_word_format", + "escape_column_names", + "dialect", + } + ) def __init__( self, @@ -60,13 +83,43 @@ def __init__( self.schema = kwargs.pop("schema", None) self._sqlalchemy_scheme = sqlalchemy_scheme + @property + def dbapi_driver(self) -> str: + """DBAPI driver, either ``pymssql`` (default) or ``mssql_python``, from the connection extra.""" + driver = self.connection_extra_lower.get("dbapi_driver") + if driver is None: + return self.DBAPI_DRIVER_PYMSSQL + + if not isinstance(driver, str) or driver.lower() not in self.SUPPORTED_DBAPI_DRIVERS: + raise ValueError( + f"Unsupported dbapi_driver {driver!r} in the connection extra. " + f"Supported values: {', '.join(sorted(self.SUPPORTED_DBAPI_DRIVERS))}" + ) + + return driver.lower() + @property def sqlalchemy_scheme(self) -> str: - """Sqlalchemy scheme either from constructor, connection extras or default.""" + """Sqlalchemy scheme either from constructor, connection extras or the default of the driver.""" extra_scheme = self.connection_extra_lower.get("sqlalchemy_scheme") if not self._sqlalchemy_scheme and extra_scheme and (":" in extra_scheme or "/" in extra_scheme): raise RuntimeError("sqlalchemy_scheme in connection extra should not contain : or / characters") - return self._sqlalchemy_scheme or extra_scheme or self.DEFAULT_SQLALCHEMY_SCHEME + return self._sqlalchemy_scheme or extra_scheme or self._default_sqlalchemy_scheme + + @property + def _default_sqlalchemy_scheme(self) -> str: + if self.dbapi_driver == self.DBAPI_DRIVER_MSSQL_PYTHON: + return self.MSSQL_PYTHON_SQLALCHEMY_SCHEME + return self.DEFAULT_SQLALCHEMY_SCHEME + + @cached_property + def placeholder(self) -> str: + """Return SQL placeholder, ``?`` for mssql_python (it has no plain ``%s`` support).""" + if self.dbapi_driver == self.DBAPI_DRIVER_MSSQL_PYTHON and not self.connection_extra.get( + "placeholder" + ): + return "?" + return super().placeholder @property def dialect_name(self) -> str: @@ -80,12 +133,12 @@ def get_uri(self) -> str: from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit r = list(urlsplit(super().get_uri())) - # change pymssql driver: + # change the sqlalchemy driver: r[0] = self.sqlalchemy_scheme - # remove query string 'sqlalchemy_scheme' like parameters: + # remove query string parameters that are meant for the hook, not for the sqlalchemy dialect: qs = parse_qs(r[3], keep_blank_values=True) for k in list(qs.keys()): - if k.lower() == "sqlalchemy_scheme": + if k.lower() in self._AIRFLOW_ONLY_EXTRAS: qs.pop(k, None) r[3] = urlencode(qs, doseq=True) return urlunsplit(r) @@ -97,10 +150,20 @@ def get_sqlalchemy_connection( engine = self.get_sqlalchemy_engine(engine_kwargs=engine_kwargs) return engine.connect(**(connect_kwargs or {})) - def get_conn(self) -> PymssqlConnection: + def get_conn(self) -> Any: + """Return a ``pymssql`` or ``mssql_python`` connection object, depending on ``dbapi_driver``.""" + if self.dbapi_driver == self.DBAPI_DRIVER_MSSQL_PYTHON: + return self._get_mssql_python_conn() + return self._get_pymssql_conn() + + def _get_pymssql_conn(self) -> PymssqlConnection: """Return ``pymssql`` connection object.""" + import pymssql + conn = self.connection - extra_conn_args = {key: val for key, val in conn.extra_dejson.items() if key != "sqlalchemy_scheme"} + extra_conn_args = { + key: val for key, val in conn.extra_dejson.items() if key.lower() not in self._AIRFLOW_ONLY_EXTRAS + } return pymssql.connect( server=conn.host or "", user=conn.login, @@ -110,16 +173,57 @@ def get_conn(self) -> PymssqlConnection: **extra_conn_args, ) - def set_autocommit( - self, - conn: PymssqlConnection, - autocommit: bool, - ) -> None: - conn.autocommit(autocommit) + def _get_mssql_python_conn(self) -> Any: + """Return ``mssql_python`` connection object.""" + try: + mssql_python = import_module("mssql_python") + except ImportError as e: + raise ImportError( + "The 'mssql_python' dbapi_driver needs the mssql-python package. Install it with: " + "pip install 'apache-airflow-providers-microsoft-mssql[mssql-python]'" + ) from e - def get_autocommit(self, conn: PymssqlConnection): + conn = self.connection + server = conn.host or "" + if server and conn.port: + server = f"{server},{conn.port}" + params: dict[str, Any] = { + "Server": server, + "Database": self.schema or conn.schema, + "UID": conn.login, + "PWD": conn.password, + } + params.update( + { + key: val + for key, val in conn.extra_dejson.items() + if key.lower() not in self._MSSQL_PYTHON_IGNORED_EXTRAS + } + ) + # The keyword arguments are escaped by mssql-python when it builds the connection string. + return mssql_python.connect(**{key: val for key, val in params.items() if val not in (None, "")}) + + def set_autocommit(self, conn: Any, autocommit: bool) -> None: + if self.dbapi_driver == self.DBAPI_DRIVER_MSSQL_PYTHON: + super().set_autocommit(conn, autocommit) + else: + conn.autocommit(autocommit) + + def get_autocommit(self, conn: Any) -> bool: + if self.dbapi_driver == self.DBAPI_DRIVER_MSSQL_PYTHON: + return super().get_autocommit(conn) return conn.autocommit_state + def _make_common_data_structure(self, result: Any) -> tuple | list[tuple] | None: + """Turn the ``Row`` objects of mssql_python into plain tuples.""" + if self.dbapi_driver != self.DBAPI_DRIVER_MSSQL_PYTHON: + return super()._make_common_data_structure(result) + if result is None: + return None + if isinstance(result, list): + return [tuple(row) for row in result] + return tuple(result) + def get_openlineage_database_info(self, connection: Connection) -> DatabaseInfo: """Return MSSQL specific information for OpenLineage.""" from airflow.providers.openlineage.sqlparser import DatabaseInfo diff --git a/providers/microsoft/mssql/tests/unit/microsoft/mssql/hooks/test_mssql.py b/providers/microsoft/mssql/tests/unit/microsoft/mssql/hooks/test_mssql.py index 991e65551de0e..b1174c77b72f1 100644 --- a/providers/microsoft/mssql/tests/unit/microsoft/mssql/hooks/test_mssql.py +++ b/providers/microsoft/mssql/tests/unit/microsoft/mssql/hooks/test_mssql.py @@ -381,3 +381,138 @@ def test_get_extra(self, get_connection, mssql_connections): hook = MsSqlHook() assert hook.get_connection().extra + + +def _mssql_hook(extra: str = "{}") -> MsSqlHook: + conn = Connection( + conn_id="mssql_default", + conn_type="mssql", + host="localhost", + login="user", + password="secret", + schema="db", + port=1433, + extra=extra, + ) + hook = MsSqlHook() + hook.get_connection = mock.Mock(return_value=conn) + return hook + + +class TestMsSqlHookDbapiDriver: + def test_default_driver_is_pymssql(self): + hook = _mssql_hook() + assert hook.dbapi_driver == "pymssql" + assert hook.sqlalchemy_scheme == "mssql+pymssql" + assert hook.placeholder == "%s" + + @pytest.mark.parametrize( + "extra", + [ + '{"dbapi_driver": "mssql_python"}', + '{"DBAPI_Driver": "MSSQL_Python"}', + ], + ) + def test_mssql_python_driver_selection(self, extra): + hook = _mssql_hook(extra) + assert hook.dbapi_driver == "mssql_python" + assert hook.sqlalchemy_scheme == "mssql+mssqlpython" + + @pytest.mark.parametrize( + "extra", + [ + pytest.param('{"dbapi_driver": "odbc"}', id="unknown-string"), + pytest.param('{"dbapi_driver": ""}', id="empty-string"), + pytest.param('{"dbapi_driver": false}', id="false"), + pytest.param('{"dbapi_driver": 0}', id="zero"), + pytest.param('{"dbapi_driver": ["mssql_python"]}', id="list"), + pytest.param('{"dbapi_driver": {"name": "mssql_python"}}', id="dict"), + ], + ) + def test_unsupported_driver_raises(self, extra): + hook = _mssql_hook(extra) + with pytest.raises(ValueError, match="Unsupported dbapi_driver"): + _ = hook.dbapi_driver + + def test_sqlalchemy_scheme_extra_wins_over_driver_default(self): + hook = _mssql_hook('{"dbapi_driver": "mssql_python", "sqlalchemy_scheme": "mssql+pyodbc"}') + assert hook.sqlalchemy_scheme == "mssql+pyodbc" + + def test_get_uri_strips_dbapi_driver(self): + hook = _mssql_hook('{"dbapi_driver": "mssql_python", "TrustServerCertificate": "yes"}') + uri = hook.get_uri() + assert uri.startswith("mssql+mssqlpython://user:secret@localhost:1433/db") + assert "dbapi_driver" not in uri + assert "TrustServerCertificate=yes" in uri + + def test_mssql_python_placeholder_defaults_to_qmark(self): + assert _mssql_hook('{"dbapi_driver": "mssql_python"}').placeholder == "?" + + def test_mssql_python_placeholder_extra_override(self): + hook = _mssql_hook('{"dbapi_driver": "mssql_python", "placeholder": "%s"}') + assert hook.placeholder == "%s" + + @mock.patch("airflow.providers.microsoft.mssql.hooks.mssql.import_module") + def test_get_conn_mssql_python(self, mock_import_module): + extra = ( + '{"dbapi_driver": "mssql_python", "sqlalchemy_scheme": "mssql+mssqlpython", ' + '"placeholder": "?", "TrustServerCertificate": "yes", "Encrypt": ""}' + ) + hook = _mssql_hook(extra) + conn = hook.get_conn() + mock_import_module.assert_called_once_with("mssql_python") + mock_import_module.return_value.connect.assert_called_once_with( + Server="localhost,1433", + Database="db", + UID="user", + PWD="secret", + TrustServerCertificate="yes", + ) + assert conn is mock_import_module.return_value.connect.return_value + + @mock.patch( + "airflow.providers.microsoft.mssql.hooks.mssql.import_module", + side_effect=ImportError("no module"), + ) + def test_get_conn_mssql_python_not_installed(self, mock_import_module): + hook = _mssql_hook('{"dbapi_driver": "mssql_python"}') + with pytest.raises(ImportError, match="mssql-python"): + hook.get_conn() + + @mock.patch("pymssql.connect") + def test_get_conn_pymssql_ignores_hook_only_extras(self, mock_connect): + hook = _mssql_hook('{"dbapi_driver": "pymssql", "sqlalchemy_scheme": "mssql+pymssql", "timeout": 5}') + hook.get_conn() + mock_connect.assert_called_once_with( + server="localhost", + user="user", + password="secret", + database="db", + port="1433", + timeout=5, + ) + + @pytest.mark.parametrize("autocommit", [True, False]) + def test_mssql_python_autocommit_uses_property(self, autocommit): + hook = _mssql_hook('{"dbapi_driver": "mssql_python"}') + conn = mock.Mock() + hook.set_autocommit(conn, autocommit) + assert conn.autocommit is autocommit + assert hook.get_autocommit(conn) is autocommit + + def test_mssql_python_rows_become_tuples(self): + class Row: + def __init__(self, *values): + self._values = values + + def __iter__(self): + return iter(self._values) + + hook = _mssql_hook('{"dbapi_driver": "mssql_python"}') + assert hook._make_common_data_structure([Row(1, "a"), Row(2, "b")]) == [(1, "a"), (2, "b")] + assert hook._make_common_data_structure(Row(1, "a")) == (1, "a") + assert hook._make_common_data_structure(None) is None + + def test_pymssql_rows_are_left_alone(self): + hook = _mssql_hook() + assert hook._make_common_data_structure([(1,), (2,)]) == [(1,), (2,)] diff --git a/uv.lock b/uv.lock index 586f624f0d7ac..15071aff09baf 100644 --- a/uv.lock +++ b/uv.lock @@ -6739,6 +6739,9 @@ dependencies = [ ] [package.optional-dependencies] +mssql-python = [ + { name = "mssql-python" }, +] openlineage = [ { name = "apache-airflow-providers-openlineage" }, ] @@ -6763,10 +6766,11 @@ requires-dist = [ { name = "apache-airflow-providers-common-sql", editable = "providers/common/sql" }, { name = "apache-airflow-providers-openlineage", marker = "extra == 'openlineage'", editable = "providers/openlineage" }, { name = "methodtools", specifier = ">=0.4.7" }, + { name = "mssql-python", marker = "extra == 'mssql-python'", specifier = ">=1.15.0" }, { name = "pymssql", marker = "python_full_version < '3.14'", specifier = ">=2.3.5" }, { name = "pymssql", marker = "python_full_version >= '3.14'", specifier = ">=2.3.13" }, ] -provides-extras = ["openlineage"] +provides-extras = ["openlineage", "mssql-python"] [package.metadata.requires-dev] dev = [ @@ -17426,6 +17430,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/7e/620e883def84ae56b8a9da382d960f7f801e37518fe930085cf72c148dae/msrestazure-0.6.4.post1-py2.py3-none-any.whl", hash = "sha256:2264493b086c2a0a82ddf5fd87b35b3fffc443819127fed992ac5028354c151e", size = 40789, upload-time = "2024-04-10T21:00:49.359Z" }, ] +[[package]] +name = "mssql-python" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-identity" }, + { name = "mssql-python-odbc" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/ed/38e52376a20f06dcd13c725a9706407ce72db5534f05242e463eb185e8f3/mssql_python-1.15.0-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:630b3f1c94ef051b5f12379607ceb88aa74d35a8cffedf37c03fa38d080504a6", size = 9935682, upload-time = "2026-09-11T15:21:47.657Z" }, + { url = "https://files.pythonhosted.org/packages/52/15/8d34096e15d59aa14cb0137561d30d049557eefd3ba9decd793ea807e6e7/mssql_python-1.15.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2e3b80c0f54342efc6d9ce162ab761f957befe3448a17c6146f688eae48b0906", size = 6073931, upload-time = "2026-09-11T15:21:50.15Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f6/050628231e49c694cf7a0def9dbc928e2f55f2dd7604f65d8598098687b7/mssql_python-1.15.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:016c61a4c82a1b69a8f8a43c91dc3537463d8ca068da73fa91f7e348b48e6d22", size = 6529330, upload-time = "2026-09-11T15:21:51.995Z" }, + { url = "https://files.pythonhosted.org/packages/47/e0/1f67b59bf027df0a9bafe743c4c7148c9f6fc8d0ce1192751aab2c4ed32b/mssql_python-1.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d58bc237ec90f6e6708fc1978ab99ff283b53508fb5adde15f63335cc23db6d3", size = 5935860, upload-time = "2026-09-11T15:21:54.043Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/b909876a86ef90cab9896279577acf3d20deb02bc2d06b3b08052cf27ee7/mssql_python-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b5aecf95d7ccc57ec7cdea9b636539aaffbadd5e69534ecc9c2a498b24263a6", size = 6343407, upload-time = "2026-09-11T15:21:55.659Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/ecaf4f78123017afe1a432da94f968a13f5dd8bf62f8ddba18775c9ca2f4/mssql_python-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:a571327f60a77fa4e515f495b6da7b53008d56a57a901ce82617add84377d9c6", size = 5187884, upload-time = "2026-09-11T15:21:57.237Z" }, + { url = "https://files.pythonhosted.org/packages/36/89/75b2eefdc22e11853c710b2921caf4a1b44bc569c9bc70121de3303e5de9/mssql_python-1.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:6a714a7ca15eff702e21d1e15856884bde4da89f438239c72ec423e69d02d56c", size = 4940428, upload-time = "2026-09-11T15:21:59.085Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ff8e4bfccf6e7bca17f67c9ca4db2375ccdfa0b4bcbfdb0a2ddcccd41e6d/mssql_python-1.15.0-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:97521247fbb2d11f957cc33de5676e284aef2923645dcdabf707b84b629ed25d", size = 9965726, upload-time = "2026-09-11T15:22:01.055Z" }, + { url = "https://files.pythonhosted.org/packages/fb/41/2ef9ddb99b672d6c47e341d14c904687b6f4e8483d4f672142596aad4f44/mssql_python-1.15.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:77b9f8ce2d95ba6bc5c83a70df57908366f0f6b84574b0055b9ba0ccf5775f7e", size = 6593472, upload-time = "2026-09-11T15:22:03.233Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4e/25879e63c11f311343e7dd424570ccce49eb6a0a51cd2464837524d35370/mssql_python-1.15.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:05cfea4fbbd1ec1ded54fd5d3a292c1cc8617d59c08d0b3005825b3e6cacf432", size = 7213764, upload-time = "2026-09-11T15:22:04.911Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/fde649be05d90b4cf07425afad6bc65439503b043e197f63f08202c94ee0/mssql_python-1.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f0943a42fca2234133aa673b444ffff67cc70c7703a6265ddf10c3d6e792c182", size = 6395871, upload-time = "2026-09-11T15:22:06.67Z" }, + { url = "https://files.pythonhosted.org/packages/c6/07/4903db1cc8eecbc3e8ed868b04ea6de936ae80d58c712085d5ea07136e66/mssql_python-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8821418222d4b3e2cdece5c37e4339133aa3d20c2ebccb1c36836f3a9a72e53a", size = 6960731, upload-time = "2026-09-11T15:22:08.322Z" }, + { url = "https://files.pythonhosted.org/packages/e2/50/603d2aea003aa186e6ca12bbc073fbdd0b6fc93eab8487a5aeae3fb050d6/mssql_python-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:88d8228cb6beccea04c03bf8606985989381f7e783527fb4c7b1b9d76d9c8a32", size = 5185162, upload-time = "2026-09-11T15:22:10.032Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9c/cfc86d803e2f7d395c5392257b251ac55ceb2edbe932b97c70f490d45276/mssql_python-1.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:b0b97f498777f874bece12f3f2210c0f954da36bd2e623da84d6dc1d5f5c6735", size = 4936441, upload-time = "2026-09-11T15:22:11.537Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/1c879dcab6f73e521b734f9224ac68b394958f267e6a278b76f6fae640d0/mssql_python-1.15.0-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:2e9d78861d4f97d779ad770b9b4af26c1aa332e2d877b9df3c8896fe766968c6", size = 9960882, upload-time = "2026-09-11T15:22:13.403Z" }, + { url = "https://files.pythonhosted.org/packages/85/26/40b7cd3a6cb5ae13b3278192f31df3fa2ef3e153255d56662d71da2ae772/mssql_python-1.15.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1614c5d32fe5ddf30bb5a576412280eeb588a7bf371a6a81de3588c741059c13", size = 7130021, upload-time = "2026-09-11T15:22:15.609Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/591359e2ce07bf8024d372484be8fbb58d292425381763487d16f2fab626/mssql_python-1.15.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:384d5729eed457047cfb3b506f489950700f10d285370d388312807b3bcffa17", size = 7913266, upload-time = "2026-09-11T15:22:17.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/aa/275d459975bc06b92826226e1f631e8a6e59e27325927bc487a8d16abf49/mssql_python-1.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f92146ce5ba3f52838041df2cc699be508a2632fc50c25599844f7b50a016fb4", size = 6872555, upload-time = "2026-09-11T15:22:19.274Z" }, + { url = "https://files.pythonhosted.org/packages/59/e6/8f9825141b9875728cba0a5f402ad408e8fbab741412c6747e98b021e639/mssql_python-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e16bcad380466cc6232852d372f48ef4c23c981f4d5e12698957b398c4bd04e2", size = 7585129, upload-time = "2026-09-11T15:22:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2f/79ee4ec452e02af4fa3146818b976c1b00f0c8d8362faf031d292c4bca9a/mssql_python-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f88f4497d2e40c75bfeb37b6fe0a790b661ac9049dd85f4a5b85143c2cd1f68", size = 5183190, upload-time = "2026-09-11T15:22:23.028Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/a51b4485b303aa536a9fec1a84ac5927b81932a9764e4cabb0645f7933ab/mssql_python-1.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:7b08b2906ff7339f9fb1752b21a6f25a8a00c36fc311a1371b9ca4c2bd66a0e3", size = 4930532, upload-time = "2026-09-11T15:22:24.855Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/0942797afac9b2cf74b59ce3255364040ba3f50ba551c701df6e6262ffdb/mssql_python-1.15.0-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:6b4f7826e22e7c0ae8613cf78509a81ce4f5d95f7a16b10493bc073653bb0176", size = 9971965, upload-time = "2026-09-11T15:22:27.039Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6a/361e2e7fd7e1fccfcc3e7e56ab6793b0018b4b61597e6afd21171a350c47/mssql_python-1.15.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:061670881c8b6f395aa1dad606fe2ff68af579367f91effde436e9f10042152d", size = 7679594, upload-time = "2026-09-11T15:22:29.259Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bf/50d2e0911f383038e59cda0730c52a82a1fcd0763d084ebefd6b3f5387cd/mssql_python-1.15.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:69640f8ea7726e9c1d7b673098baec4b7b637bb5b144764e2e688cade664064d", size = 8642553, upload-time = "2026-09-11T15:22:31.258Z" }, + { url = "https://files.pythonhosted.org/packages/0c/67/5fb9ec913454cddd4a27ce9e142b1b5edc01d96dec25e8ec2c15ef12311f/mssql_python-1.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dcb141191daf6214f614907e40de2559aba9ff97f570ab2d4e3c1f02958c7e76", size = 7359238, upload-time = "2026-09-11T15:22:35.151Z" }, + { url = "https://files.pythonhosted.org/packages/96/c9/f3bdb4bf3fcac6102abdc4194a4b43b77a8da155e69c4c8aa1c18dbce265/mssql_python-1.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c7305c55ba30bb9135e7273ffcb6624a1d25546c8b09033e12d9631d5f1e8e65", size = 8240128, upload-time = "2026-09-11T15:22:36.92Z" }, + { url = "https://files.pythonhosted.org/packages/af/e8/0793bdcd7016adf52c8f39ac8e1abd1f0eeef658c75d25e90ecc36a3a733/mssql_python-1.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:cf265057da5337aa134358b97f80b0e4a118661848ccaff52db61d912c36d6f5", size = 5407508, upload-time = "2026-09-11T15:22:38.51Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f3/175854dc9ca8ad74cd1823c94d82cfbd58b8f9602401f056f834245125d6/mssql_python-1.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:f91c5d9027ed044d0d954525a05ce87fbcc5ac5a9c02072f0e50e391f37ed3dc", size = 5167181, upload-time = "2026-09-11T15:43:51.102Z" }, +] + +[[package]] +name = "mssql-python-odbc" +version = "18.6.2.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/21/5d8e34820d986426dc65f88702ccecd1a981184e6bffab2968011461eaef/mssql_python_odbc-18.6.2.1-py3-none-macosx_15_0_universal2.whl", hash = "sha256:c0c4d2446f37656be1ed32c8e3229233554c1fbfc9f23c73c2ab0d07a2bfdcf2", size = 2035381, upload-time = "2026-08-05T15:51:46.762Z" }, + { url = "https://files.pythonhosted.org/packages/85/98/e07617483dd395156fba003cda6ff85ca9ad64de1347b823951f2afb3d60/mssql_python_odbc-18.6.2.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d665529c195829970dcb72b7b3200e3c0ee1b053bee88e9a6eabaa385874dd6e", size = 2730032, upload-time = "2026-08-05T15:51:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/35/1f/6cdb575549fbbe6f49bcadb505768649c43226bd83ec57a53a934d48e459/mssql_python_odbc-18.6.2.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b8aad821f3b8637b0e6d4637f4b730ed9897c4665fa99d47e44e97fd1c79c77a", size = 3897879, upload-time = "2026-08-05T15:51:50.134Z" }, + { url = "https://files.pythonhosted.org/packages/89/ed/fa18a5f48eb4f0f003010af808b032806082c5441a076d698835ed5151b6/mssql_python_odbc-18.6.2.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bcfca2a994b17499dc24a8b40228b851ccd0cd5807def855a6c41a8cedc4f8ac", size = 2730032, upload-time = "2026-08-05T15:51:51.672Z" }, + { url = "https://files.pythonhosted.org/packages/6c/66/7c96d719dfe7e9dcbbbf7acb345c72dfaef392fa6c5042e2718dfb1779b4/mssql_python_odbc-18.6.2.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3db256103b09156f0980bee2dedc47995f528f76d01388ab5c5f1758b7e8fc53", size = 3897877, upload-time = "2026-08-05T15:51:53.147Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f6/e7725d5dc791f5535fa7ec750c2ab9a1ff731f923d01b9c0d5cf1779b676/mssql_python_odbc-18.6.2.1-py3-none-win_amd64.whl", hash = "sha256:07a987221f99b368161db912065ef9e64c85f6b0901a2b05e9c1e6b41da08d55", size = 3712266, upload-time = "2026-08-05T15:51:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/cda7ae46dd3c723196043109204fcc7749d688194de834b3f8aa4cd94415/mssql_python_odbc-18.6.2.1-py3-none-win_arm64.whl", hash = "sha256:00e1eae3c44a157ca60f992c6305be45856f18276ccff5258c1e9619847f3a5a", size = 7017266, upload-time = "2026-08-05T15:51:56.958Z" }, +] + [[package]] name = "multi-key-dict" version = "2.0.3"