diff --git a/.github/workflows/ibis-backends.yml b/.github/workflows/ibis-backends.yml index bc7e1f9f1328..5460a4dde50b 100644 --- a/.github/workflows/ibis-backends.yml +++ b/.github/workflows/ibis-backends.yml @@ -140,7 +140,8 @@ jobs: - --extra polars sys-deps: - libgeos-dev - - default-libmysqlclient-dev + adbc-drivers: + - mysql - name: singlestoredb title: SingleStoreDB serial: true @@ -322,7 +323,8 @@ jobs: - mysql sys-deps: - libgeos-dev - - default-libmysqlclient-dev + adbc-drivers: + - mysql - os: windows-latest backend: name: singlestoredb @@ -500,6 +502,16 @@ jobs: sudo apt-get update -qq -y sudo apt-get install -qq -y build-essential ${{ join(matrix.backend.sys-deps, ' ') }} + - name: install adbc drivers + if: matrix.backend.adbc-drivers != null + run: | + set -euo pipefail + + pipx install dbc + for driver in ${{ join(matrix.backend.adbc-drivers, ' ') }}; do + dbc install "$driver" + done + - name: install sqlite if: matrix.os == 'windows-latest' && matrix.backend.name == 'sqlite' run: choco install sqlite diff --git a/docs/_quarto.yml b/docs/_quarto.yml index 794d29234b97..da35fd487370 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -25,7 +25,7 @@ interlinks: arrow: url: https://arrow.apache.org/docs/ pandas: - url: http://pandas.pydata.org/pandas-docs/stable/ + url: https://pandas.pydata.org/docs/ website: title: "Ibis" diff --git a/docs/backends/mysql.qmd b/docs/backends/mysql.qmd index 11eb0b92b0be..87371907a8b8 100644 --- a/docs/backends/mysql.qmd +++ b/docs/backends/mysql.qmd @@ -18,6 +18,18 @@ Install with the `mysql` extra: pip install 'ibis-framework[mysql]' ``` +The MySQL backend uses the [ADBC MySQL driver](https://docs.adbc-drivers.org/drivers/mysql/index.html), +which sends and receives data using the efficient columnar Apache Arrow format +instead of the traditional row-wise protocol. + +You must also install the MySQL ADBC driver binary. +[There are several installation options](https://docs.adbc-drivers.org/drivers/mysql/index.html) +(e.g. pipx, Homebrew, a bash installer, etc.), but if you have `uv` installed: + +```{.bash} +uvx dbc install mysql +``` + And connect: ```{.python} @@ -36,6 +48,14 @@ Install for MySQL: conda install -c conda-forge ibis-mysql ``` +You must also install the MySQL ADBC driver binary. +[There are several installation options](https://docs.adbc-drivers.org/drivers/mysql/index.html) +(e.g. pipx, Homebrew, a bash installer, etc.), but if you have `uv` installed: + +```{.bash} +uvx dbc install mysql +``` + And connect: ```{.python} @@ -54,6 +74,14 @@ Install for MySQL: mamba install -c conda-forge ibis-mysql ``` +You must also install the MySQL ADBC driver binary. +[There are several installation options](https://docs.adbc-drivers.org/drivers/mysql/index.html) +(e.g. pipx, Homebrew, a bash installer, etc.), but if you have `uv` installed: + +```{.bash} +uvx dbc install mysql +``` + And connect: ```{.python} diff --git a/ibis/backends/mysql/__init__.py b/ibis/backends/mysql/__init__.py index 93dd6affc214..d16b42a8a79c 100644 --- a/ibis/backends/mysql/__init__.py +++ b/ibis/backends/mysql/__init__.py @@ -6,19 +6,17 @@ import getpass import warnings from functools import cached_property -from operator import itemgetter -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from urllib.parse import unquote_plus -import MySQLdb import sqlglot as sg import sqlglot.expressions as sge -from MySQLdb import ProgrammingError -from MySQLdb.constants import ER +from adbc_driver_manager import dbapi as adbc_dbapi import ibis import ibis.backends.sql.compilers as sc import ibis.common.exceptions as com +import ibis.expr.datatypes as dt import ibis.expr.operations as ops import ibis.expr.schema as sch import ibis.expr.types as ir @@ -30,7 +28,7 @@ SupportsTempTables, ) from ibis.backends.sql import SQLBackend -from ibis.backends.sql.compilers.base import STAR, TRUE, C, RenameTable +from ibis.backends.sql.compilers.base import RenameTable if TYPE_CHECKING: from collections.abc import Mapping @@ -70,7 +68,10 @@ def _from_url(self, url: ParseResult, **kwarg_overrides): @cached_property def version(self): - return ".".join(map(str, self.con._server_version)) + with self.con.cursor() as cur: + cur.execute("SELECT VERSION()") + result = cur.fetch_arrow_table() + return result.column(0)[0].as_py() def do_connect( self, @@ -78,7 +79,8 @@ def do_connect( user: str | None = None, password: str | None = None, port: int = 3306, - autocommit: bool = True, + database: str | None = None, + autocommit: Literal[True] = True, **kwargs, ) -> None: """Create an Ibis client using the passed connection parameters. @@ -93,10 +95,13 @@ def do_connect( Password port Port + database + Database to connect to autocommit - Autocommit mode + Whether to use autocommit mode. Only ``True`` is supported at this + time due to a limitation of the ADBC MySQL driver. kwargs - Additional keyword arguments passed to `MySQLdb.connect` + Additional keyword arguments Examples -------- @@ -126,116 +131,124 @@ def do_connect( year int32 month int32 """ - self.con = MySQLdb.connect( - user=user or getpass.getuser(), - host="127.0.0.1" if host == "localhost" else host, - port=port, - password=password or "", - autocommit=autocommit, - **kwargs, + user = user or getpass.getuser() + host = "127.0.0.1" if host == "localhost" else host + password = password or "" + + # Also accept db from kwargs for backwards compat + if database is None: + db = kwargs.pop("db", None) + if db is not None: + warnings.warn( + "Passing `db` is deprecated, use `database` instead.", + DeprecationWarning, + stacklevel=2, + ) + database = db + + autocommit = bool(autocommit) + if not autocommit: + raise ValueError( + "The MySQL backend only supports `autocommit=True` at this time. " + "See https://github.com/ibis-project/ibis/pull/11958 for details." + ) + + uri = f"{user}:{password}@tcp({host}:{port})/{database or ''}" + self.con = adbc_dbapi.connect( + driver="mysql", db_kwargs={"uri": uri}, autocommit=autocommit ) self._post_connect() - @util.experimental - @classmethod - def from_connection(cls, con: MySQLdb.Connection, /) -> Backend: - """Create an Ibis client from an existing connection to a MySQL database. + def _post_connect(self) -> None: + with self.con.cursor() as cur: + try: + cur.execute("SET @@session.time_zone = 'UTC'") + except Exception as e: # noqa: BLE001 + warnings.warn(f"Unable to set session timezone to UTC: {e}") - Parameters - ---------- - con - An existing connection to a MySQL database. - """ + @classmethod + def from_connection(cls, con: adbc_dbapi.Connection, /, **kwargs) -> Backend: new_backend = cls() new_backend._can_reconnect = False new_backend.con = con new_backend._post_connect() return new_backend - def _post_connect(self) -> None: - with self.con.cursor() as cur: - try: - cur.execute("SET @@session.time_zone = 'UTC'") - except Exception as e: # noqa: BLE001 - warnings.warn(f"Unable to set session timezone to UTC: {e}") + def disconnect(self) -> None: + self.con.close() @property def current_database(self) -> str: - with self._safe_raw_sql(sg.select(self.compiler.f.database())) as cur: - [(database,)] = cur.fetchall() - return database + sql = sg.select(self.compiler.f.database()).sql(self.dialect) + with self.con.cursor() as cur: + cur.execute(sql) + table = cur.fetch_arrow_table() + return table.column(0)[0].as_py() def list_databases(self, *, like: str | None = None) -> list[str]: # In MySQL, "database" and "schema" are synonymous - with self._safe_raw_sql("SHOW DATABASES") as cur: - databases = list(map(itemgetter(0), cur.fetchall())) + result = self.con.adbc_get_objects(depth="catalogs").read_all() + databases = result.column("catalog_name").to_pylist() return self._filter_with_like(databases, like) - def _get_schema_using_query(self, query: str) -> sch.Schema: - from ibis.backends.mysql.datatypes import _type_from_cursor_info - - char_set_info = self.con.get_character_set_info() - multi_byte_maximum_length = char_set_info["mbmaxlen"] - - sql = ( - sg.select(STAR) - .from_( - sg.parse_one(query, dialect=self.dialect).subquery( - sg.to_identifier( - util.gen_name("query_schema"), quoted=self.compiler.quoted - ) + @staticmethod + def _schema_from_adbc_execute_schema(pyarrow_schema) -> sch.Schema: + from ibis.formats.pyarrow import PyArrowType + + fields = {} + for field in pyarrow_schema: + meta = {k.decode(): v.decode() for k, v in (field.metadata or {}).items()} + db_type = meta.get("sql.database_type_name", "") + + if db_type.startswith("UNSIGNED"): + base = db_type.removeprefix("UNSIGNED ").lower() + fields[field.name] = sc.mysql.MySQLType.from_string( + f"{base} unsigned", nullable=field.nullable ) - ) - .limit(0) - .sql(self.dialect) - ) - with self.begin() as cur: - cur.execute(sql) - descr, flags = cur.description, cur.description_flags - - items = {} - for (name, type_code, _, _, field_length, scale, _), raw_flags in zip( - descr, flags - ): - items[name] = _type_from_cursor_info( - flags=raw_flags, - type_code=type_code, - field_length=field_length, - scale=scale, - multi_byte_maximum_length=multi_byte_maximum_length, - ) - return sch.Schema(items) + elif db_type == "DECIMAL": + p = int(meta["sql.precision"]) + s = int(meta["sql.scale"]) + fields[field.name] = dt.Decimal(p, s, nullable=field.nullable) + elif db_type in ("DATETIME", "TIMESTAMP"): + scale = int(meta.get("sql.fractional_seconds_precision", 0)) + tz = "UTC" if db_type == "TIMESTAMP" else None + fields[field.name] = dt.Timestamp( + timezone=tz, scale=scale or None, nullable=field.nullable + ) + elif db_type == "YEAR": + fields[field.name] = dt.UInt8(nullable=field.nullable) + elif db_type == "SET": + fields[field.name] = dt.Array(dt.string, nullable=field.nullable) + else: + fields[field.name] = PyArrowType.to_ibis(field.type, field.nullable) + + return sch.Schema(fields) + + def _get_schema_using_query(self, query: str) -> sch.Schema: + with self.con.cursor() as cur: + pyarrow_schema = cur.adbc_execute_schema(str(query)) + return self._schema_from_adbc_execute_schema(pyarrow_schema) def get_schema( self, name: str, *, catalog: str | None = None, database: str | None = None ) -> sch.Schema: table = sg.table( name, db=database, catalog=catalog, quoted=self.compiler.quoted - ).sql(self.dialect) - - with self.begin() as cur: - try: - cur.execute(sge.Describe(this=table).sql(self.dialect)) - except ProgrammingError as e: - if e.args[0] == ER.NO_SUCH_TABLE: - raise com.TableNotFound(name) from e - else: - result = cur.fetchall() - - type_mapper = self.compiler.type_mapper - fields = { - name: type_mapper.from_string(type_string, nullable=is_nullable == "YES") - for name, type_string, is_nullable, *_ in result - } - - return sch.Schema(fields) + ) + query = sg.select("*").from_(table).sql(self.dialect) + try: + return self._get_schema_using_query(query) + except Exception as e: + if getattr(e, "sqlstate", None) == "42S02": + raise com.TableNotFound(name) from e + raise def create_database(self, name: str, force: bool = False) -> None: sql = sge.Create( kind="DATABASE", exists=force, this=sg.to_identifier(name) ).sql(self.name) - with self.begin() as cur: + with self.con.cursor() as cur: cur.execute(sql) def drop_database( @@ -244,32 +257,17 @@ def drop_database( sql = sge.Drop( kind="DATABASE", exists=force, this=sg.table(name, catalog=catalog) ).sql(self.name) - with self.begin() as cur: + with self.con.cursor() as cur: cur.execute(sql) @contextlib.contextmanager def begin(self): - con = self.con - cur = con.cursor() - autocommit = con.get_autocommit() - - if not autocommit: - con.begin() - + cur = self.con.cursor() try: yield cur - except Exception: - if not autocommit: - con.rollback() - raise - else: - if not autocommit: - con.commit() finally: cur.close() - # TODO(kszucs): should make it an abstract method or remove the use of it - # from .execute() @contextlib.contextmanager def _safe_raw_sql(self, *args, **kwargs): with self.raw_sql(*args, **kwargs) as result: @@ -279,24 +277,13 @@ def raw_sql(self, query: str | sg.Expression, **kwargs: Any) -> Any: with contextlib.suppress(AttributeError): query = query.sql(dialect=self.name) - con = self.con - autocommit = con.get_autocommit() - - cursor = con.cursor() - - if not autocommit: - con.begin() - + cursor = self.con.cursor() try: cursor.execute(query, **kwargs) except Exception: - if not autocommit: - con.rollback() cursor.close() raise else: - if not autocommit: - con.commit() return cursor # TODO: disable positional arguments @@ -307,34 +294,24 @@ def list_tables( ) -> list[str]: if database is not None: table_loc = self._to_sqlglot_table(database) + # In MySQL, catalog and db are both "database" + catalog = table_loc.catalog or table_loc.db else: - table_loc = sge.Table( - db=sg.to_identifier(self.current_database, quoted=self.compiler.quoted), - catalog=None, - ) - - conditions = [TRUE] - - if (sg_cat := table_loc.args["catalog"]) is not None: - sg_cat.args["quoted"] = False - if (sg_db := table_loc.args["db"]) is not None: - sg_db.args["quoted"] = False - if table_loc.catalog or table_loc.db: - conditions = [C.table_schema.eq(sge.convert(table_loc.sql(self.name)))] - - col = "table_name" - sql = ( - sg.select(col) - .from_(sg.table("tables", db="information_schema")) - .distinct() - .where(*conditions) - .sql(self.name) - ) - - with self._safe_raw_sql(sql) as cur: - out = cur.fetchall() - - return self._filter_with_like(map(itemgetter(0), out), like) + catalog = self.current_database + + result = self.con.adbc_get_objects( + depth="tables", catalog_filter=catalog + ).read_all() + catalogs = result.to_pydict() + tables = [ + table["table_name"] + for schemas in catalogs.get("catalog_db_schemas", []) + if schemas is not None + for schema in schemas + for table in schema.get("db_schema_tables") or [] + ] + + return self._filter_with_like(tables, like) def execute( self, @@ -366,8 +343,18 @@ def execute( schema = table.schema() - with self._safe_raw_sql(sql) as cur: - result = self._fetch_from_cursor(cur, schema) + with self.con.cursor() as cur: + cur.execute(sql) + arrow_table = cur.fetch_arrow_table() + + import pandas as pd + + from ibis.formats.pandas import PandasData + + df = arrow_table.to_pandas(timestamp_as_object=False) + if df.empty: + df = pd.DataFrame(columns=schema.names) + result = PandasData.convert_table(df, schema) return expr.__pandas_result__(result) def create_table( @@ -460,33 +447,10 @@ def _register_in_memory_table(self, op: ops.InMemoryTable) -> None: f"got null typed columns: {null_columns}" ) - name = op.name - quoted = self.compiler.quoted - dialect = self.dialect + arrow_table = op.data.to_pyarrow(schema) - create_stmt = sg.exp.Create( - kind="TABLE", - this=sg.exp.Schema( - this=sg.to_identifier(name, quoted=quoted), - expressions=schema.to_sqlglot_column_defs(dialect), - ), - properties=sg.exp.Properties(expressions=[sge.TemporaryProperty()]), - ) - create_stmt_sql = create_stmt.sql(dialect) - - df = op.data.to_frame() - # nan can not be used with MySQL - df = df.replace(float("nan"), None) - - data = df.itertuples(index=False) - sql = self._build_insert_template( - name, schema=schema, columns=True, placeholder="%s" - ) - with self.begin() as cur: - cur.execute(create_stmt_sql) - - if not df.empty: - cur.executemany(sql, data) + with self.con.cursor() as cur: + cur.adbc_ingest(op.name, arrow_table, mode="create", temporary=True) @util.experimental def to_pyarrow_batches( @@ -503,22 +467,18 @@ def to_pyarrow_batches( self._run_pre_execute_hooks(expr) - schema = expr.as_table().schema() - with self._safe_raw_sql( - self.compile(expr, limit=limit, params=params) - ) as cursor: - df = self._fetch_from_cursor(cursor, schema) - table = pa.Table.from_pandas( - df, schema=schema.to_pyarrow(), preserve_index=False - ) - return table.to_reader(max_chunksize=chunk_size) + table_expr = expr.as_table() + sql = self.compile(table_expr, limit=limit, params=params) + target_schema = table_expr.schema().to_pyarrow() - def _fetch_from_cursor(self, cursor, schema: sch.Schema) -> pd.DataFrame: - import pandas as pd + cur = self.raw_sql(sql) + reader = cur.fetch_record_batch() - from ibis.backends.mysql.converter import MySQLPandasData + def batch_producer(): + try: + for batch in reader: + yield batch.rename_columns(target_schema.names) + finally: + cur.close() - df = pd.DataFrame.from_records( - cursor.fetchall(), columns=schema.names, coerce_float=True - ) - return MySQLPandasData.convert_table(df, schema) + return pa.ipc.RecordBatchReader.from_batches(target_schema, batch_producer()) diff --git a/ibis/backends/mysql/converter.py b/ibis/backends/mysql/converter.py deleted file mode 100644 index 4f2010225a5b..000000000000 --- a/ibis/backends/mysql/converter.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -import datetime - -from ibis.formats.pandas import PandasData - - -class MySQLPandasData(PandasData): - # TODO(kszucs): this could be reused at other backends, like pyspark - @classmethod - def convert_Time(cls, s, dtype, pandas_type): - def convert(timedelta): - comps = timedelta.components - return datetime.time( - hour=comps.hours, - minute=comps.minutes, - second=comps.seconds, - microsecond=comps.milliseconds * 1000 + comps.microseconds, - ) - - return s.map(convert, na_action="ignore") - - @classmethod - def convert_Timestamp(cls, s, dtype, pandas_type): - if s.dtype == "object": - s = s.replace("0000-00-00 00:00:00", None) - return super().convert_Timestamp(s, dtype, pandas_type) diff --git a/ibis/backends/mysql/datatypes.py b/ibis/backends/mysql/datatypes.py deleted file mode 100644 index 5888043259e1..000000000000 --- a/ibis/backends/mysql/datatypes.py +++ /dev/null @@ -1,147 +0,0 @@ -from __future__ import annotations - -import inspect -from functools import partial - -from MySQLdb.constants import FIELD_TYPE, FLAG - -import ibis.expr.datatypes as dt - -TEXT_TYPES = ( - FIELD_TYPE.BIT, - FIELD_TYPE.BLOB, - FIELD_TYPE.LONG_BLOB, - FIELD_TYPE.MEDIUM_BLOB, - FIELD_TYPE.STRING, - FIELD_TYPE.TINY_BLOB, - FIELD_TYPE.VAR_STRING, - FIELD_TYPE.VARCHAR, - FIELD_TYPE.GEOMETRY, -) - - -def _type_from_cursor_info( - *, flags, type_code, field_length, scale, multi_byte_maximum_length -) -> dt.DataType: - """Construct an ibis type from MySQL field descr and field result metadata. - - This method is complex because the MySQL protocol is complex. - - Types are not encoded in a self contained way, meaning you need - multiple pieces of information coming from the result set metadata to - determine the most precise type for a field. Even then, the decoding is - not high fidelity in some cases: UUIDs for example are decoded as - strings, because the protocol does not appear to preserve the logical - type, only the physical type. - """ - flags = _FieldFlags(flags) - typename = _type_codes.get(type_code) - if typename is None: - raise NotImplementedError(f"MySQL type code {type_code:d} is not supported") - - if typename in ("DECIMAL", "NEWDECIMAL"): - precision = _decimal_length_to_precision( - length=field_length, scale=scale, is_unsigned=flags.is_unsigned - ) - typ = partial(_type_mapping[typename], precision=precision, scale=scale) - elif typename == "BIT": - if field_length <= 8: - typ = dt.int8 - elif field_length <= 16: - typ = dt.int16 - elif field_length <= 32: - typ = dt.int32 - elif field_length <= 64: - typ = dt.int64 - else: - raise AssertionError("invalid field length for BIT type") - elif flags.is_set: - # sets are limited to strings - typ = dt.Array(dt.string) - elif type_code in TEXT_TYPES: - if flags.is_binary: - typ = dt.Binary - else: - typ = partial(dt.String, length=field_length // multi_byte_maximum_length) - elif flags.is_timestamp or typename == "TIMESTAMP": - typ = partial(dt.Timestamp, timezone="UTC", scale=scale or None) - elif typename == "DATETIME": - typ = partial(dt.Timestamp, scale=scale or None) - else: - typ = _type_mapping[typename] - if issubclass(typ, dt.SignedInteger) and flags.is_unsigned: - typ = getattr(dt, f"U{typ.__name__}") - - # projection columns are always nullable - return typ(nullable=True) - - -# ported from my_decimal.h:my_decimal_length_to_precision in mariadb -def _decimal_length_to_precision(*, length: int, scale: int, is_unsigned: bool) -> int: - return length - (scale > 0) - (not (is_unsigned or not length)) - - -_type_codes = {v: k for k, v in inspect.getmembers(FIELD_TYPE) if not k.startswith("_")} - - -_type_mapping = { - "DECIMAL": dt.Decimal, - "TINY": dt.Int8, - "SHORT": dt.Int16, - "LONG": dt.Int32, - "FLOAT": dt.Float32, - "DOUBLE": dt.Float64, - "NULL": dt.Null, - "LONGLONG": dt.Int64, - "INT24": dt.Int32, - "DATE": dt.Date, - "TIME": dt.Time, - "DATETIME": dt.Timestamp, - "YEAR": dt.UInt8, - "VARCHAR": dt.String, - "JSON": dt.JSON, - "NEWDECIMAL": dt.Decimal, - "ENUM": dt.String, - "SET": partial(dt.Array, dt.string), - "TINY_BLOB": dt.Binary, - "MEDIUM_BLOB": dt.Binary, - "LONG_BLOB": dt.Binary, - "BLOB": dt.Binary, - "VAR_STRING": dt.String, - "STRING": dt.String, - "GEOMETRY": dt.Geometry, -} - - -class _FieldFlags: - """Flags used to disambiguate field types. - - Gaps in the flag numbers are because we do not map in flags that are - of no use in determining the field's type, such as whether the field - is a primary key or not. - """ - - __slots__ = ("value",) - - def __init__(self, value: int) -> None: - self.value = value - - @property - def is_unsigned(self) -> bool: - return (FLAG.UNSIGNED & self.value) != 0 - - @property - def is_timestamp(self) -> bool: - return (FLAG.TIMESTAMP & self.value) != 0 - - @property - def is_set(self) -> bool: - return (FLAG.SET & self.value) != 0 - - @property - def is_num(self) -> bool: - return (FLAG.NUM & self.value) != 0 - - @property - def is_binary(self) -> bool: - return (FLAG.BINARY & self.value) != 0 diff --git a/ibis/backends/mysql/tests/conftest.py b/ibis/backends/mysql/tests/conftest.py index 02c6b552549b..7f9b6a846679 100644 --- a/ibis/backends/mysql/tests/conftest.py +++ b/ibis/backends/mysql/tests/conftest.py @@ -29,36 +29,30 @@ class TestConf(ServiceBackendTest): supports_structs = False rounding_method = "half_to_even" service_name = "mysql" - deps = ("MySQLdb",) + deps = ("adbc_driver_manager",) @property def test_files(self) -> Iterable[Path]: return self.data_dir.joinpath("csv").glob("*.csv") def _load_data(self, **kwargs: Any) -> None: - """Load test data into a MySql backend instance. + """Load test data into a MySQL backend instance.""" + import pyarrow.csv as pcsv - Parameters - ---------- - data_dir - Location of testdata - script_dir - Location of scripts defining schemas - """ super()._load_data(**kwargs) - with self.connection.begin() as cur: - for table in TEST_TABLES: + with self.connection.con.cursor() as cur: + for table, schema in TEST_TABLES.items(): csv_path = self.data_dir / "csv" / f"{table}.csv" - lines = [ - f"LOAD DATA LOCAL INFILE {str(csv_path)!r}", - f"INTO TABLE {table}", - "COLUMNS TERMINATED BY ','", - """OPTIONALLY ENCLOSED BY '"'""", - "LINES TERMINATED BY '\\n'", - "IGNORE 1 LINES", - ] - cur.execute("\n".join(lines)) + arrow_schema = schema.to_pyarrow() + arrow_table = pcsv.read_csv( + csv_path, + convert_options=pcsv.ConvertOptions( + column_types=arrow_schema, + strings_can_be_null=True, + ), + ) + cur.adbc_ingest(table, arrow_table, mode="append") @staticmethod def connect(*, tmpdir, worker_id, **kw): # noqa: ARG004 @@ -68,8 +62,6 @@ def connect(*, tmpdir, worker_id, **kw): # noqa: ARG004 password=MYSQL_PASS, database=IBIS_TEST_MYSQL_DB, port=MYSQL_PORT, - local_infile=1, - autocommit=True, **kw, ) diff --git a/ibis/backends/mysql/tests/test_client.py b/ibis/backends/mysql/tests/test_client.py index e5c1f02911b1..d7a75b38d64b 100644 --- a/ibis/backends/mysql/tests/test_client.py +++ b/ibis/backends/mysql/tests/test_client.py @@ -63,6 +63,12 @@ param("int unsigned", dt.uint32, id="int-unsigned"), param("smallint unsigned", dt.uint16, id="smallint-unsigned"), param("tinyint unsigned", dt.uint8, id="tinyint-unsigned"), + param("json", dt.string, id="json"), + param("inet6", dt.inet, id="inet"), + param("uuid", dt.uuid, id="uuid"), + param("enum('small', 'medium', 'large')", dt.string, id="enum"), + param("mediumtext", dt.string, id="mediumtext"), + param("text", dt.string, id="text"), ] + [ param( f"datetime({scale:d})", @@ -73,8 +79,23 @@ ] +_ADBC_EXECUTE_SCHEMA_XFAILS = { + # ADBC driver reports uuid/inet6 as CHAR, no way to distinguish + "uuid", + "inet", + # ADBC driver doesn't report bit width in metadata + "bit_1", + "bit_9", + "bit_17", + "bit_33", + # Arrow string type cannot represent length + "char", + "varchar", +} + + @pytest.mark.parametrize(("mysql_type", "expected_type"), MYSQL_TYPES) -def test_get_schema_from_query(con, mysql_type, expected_type): +def test_get_schema_from_query(con, mysql_type, expected_type, request): raw_name = ibis.util.guid() name = sg.to_identifier(raw_name, quoted=True).sql("mysql") expected_schema = ibis.schema(dict(x=expected_type)) @@ -85,48 +106,18 @@ def test_get_schema_from_query(con, mysql_type, expected_type): c.execute(f"CREATE TEMPORARY TABLE {name} (x {mysql_type})") result_schema = con._get_schema_using_query(f"SELECT * FROM {name}") + param_id = request.node.callspec.id + if param_id in _ADBC_EXECUTE_SCHEMA_XFAILS: + if result_schema != expected_schema: + pytest.xfail( + reason=f"ADBC execute_schema metadata insufficient for {mysql_type}" + ) assert result_schema == expected_schema t = con.table(raw_name) assert t.schema() == expected_schema -@pytest.mark.parametrize( - ("mysql_type", "get_schema_expected_type", "table_expected_type"), - [ - param("json", dt.binary, dt.string, id="json"), - param("inet6", dt.binary, dt.inet, id="inet"), - param("uuid", dt.binary, dt.uuid, id="uuid"), - param( - "enum('small', 'medium', 'large')", - dt.String(length=6), - dt.string, - id="enum", - ), - param("mediumtext", dt.String(length=2**24 - 1), dt.string, id="mediumtext"), - param("text", dt.String(length=2**16 - 1), dt.string, id="text"), - ], -) -def test_get_schema_from_query_special_cases( - con, mysql_type, get_schema_expected_type, table_expected_type -): - raw_name = ibis.util.guid() - name = sg.to_identifier(raw_name, quoted=True).sql("mysql") - get_schema_expected_schema = ibis.schema(dict(x=get_schema_expected_type)) - table_expected_schema = ibis.schema(dict(x=table_expected_type)) - - # temporary tables get cleaned up by the db when the session ends, so we - # don't need to explicitly drop the table - with con.begin() as c: - c.execute(f"CREATE TEMPORARY TABLE {name} (x {mysql_type})") - - result_schema = con._get_schema_using_query(f"SELECT * FROM {name}") - assert result_schema == get_schema_expected_schema - - t = con.table(raw_name) - assert t.schema() == table_expected_schema - - @pytest.mark.parametrize("coltype", ["TINYBLOB", "MEDIUMBLOB", "BLOB", "LONGBLOB"]) def test_blob_type(con, coltype): tmp = f"tmp_{ibis.util.guid()}" @@ -145,11 +136,19 @@ def tmp_t(con): c.execute("DROP TABLE IF EXISTS test_schema.t") +@pytest.mark.xfail( + reason="ADBC driver reports MariaDB inet6 as CHAR", +) def test_get_schema_from_query_other_schema(con, tmp_t): t = con.table(tmp_t, database="test_schema") assert t.schema() == ibis.schema({"x": dt.inet}) +@pytest.mark.notyet( + ["mysql"], + raises=Exception, + reason="ADBC MySQL driver cannot parse zero timestamps ('0000-00-00 00:00:00')", +) def test_zero_timestamp_data(con): sql = """ CREATE TEMPORARY TABLE ztmp_date_issue @@ -176,8 +175,8 @@ def test_zero_timestamp_data(con): "name": ["C", "B", "C"], "tradedate": pd.to_datetime( [date(2018, 10, 22), date(2017, 6, 7), date(2022, 12, 21)] - ), - "date": [pd.NaT, pd.NaT, pd.NaT], + ).as_unit("s"), + "date": pd.array([pd.NaT, pd.NaT, pd.NaT], dtype="datetime64[s]"), } ) tm.assert_frame_equal(result, expected) diff --git a/ibis/backends/sql/datatypes.py b/ibis/backends/sql/datatypes.py index de563f7af6d6..f800f3a9ec6d 100644 --- a/ibis/backends/sql/datatypes.py +++ b/ibis/backends/sql/datatypes.py @@ -672,6 +672,11 @@ class MySQLType(SqlglotType): unknown_type_strings = FrozenDict({"year(4)": dt.uint8, "inet6": dt.inet}) + @classmethod + def _from_ibis_Binary(cls, dtype: dt.Binary) -> sge.DataType: + # MySQL VARBINARY requires a length; use BLOB for arbitrary binary data + return sge.DataType(this=typecode.BLOB) + @classmethod def _from_sqlglot_BIT( cls, nbits: sge.DataTypeParam, nullable: bool | None = None diff --git a/ibis/backends/tests/errors.py b/ibis/backends/tests/errors.py index 20da3395013e..ba82fd7bee1d 100644 --- a/ibis/backends/tests/errors.py +++ b/ibis/backends/tests/errors.py @@ -159,9 +159,9 @@ ) = PsycoPgUndefinedObject = PsycoPgArraySubscriptError = None try: - from MySQLdb import NotSupportedError as MySQLNotSupportedError - from MySQLdb import OperationalError as MySQLOperationalError - from MySQLdb import ProgrammingError as MySQLProgrammingError + from adbc_driver_manager import NotSupportedError as MySQLNotSupportedError + from adbc_driver_manager import OperationalError as MySQLOperationalError + from adbc_driver_manager import ProgrammingError as MySQLProgrammingError except ImportError: MySQLNotSupportedError = MySQLProgrammingError = MySQLOperationalError = None diff --git a/ibis/backends/tests/snapshots/test_temporal/test_temporal_literal_sql/mysql-date/out.sql b/ibis/backends/tests/snapshots/test_temporal/test_temporal_literal_sql/mysql-date/out.sql new file mode 100644 index 000000000000..e1fdc1f7b49b --- /dev/null +++ b/ibis/backends/tests/snapshots/test_temporal/test_temporal_literal_sql/mysql-date/out.sql @@ -0,0 +1,2 @@ +SELECT + DATE('2023-04-07') AS `datetime.date(2023, 4, 7)` \ No newline at end of file diff --git a/ibis/backends/tests/snapshots/test_temporal/test_temporal_literal_sql/mysql-timestamp/out.sql b/ibis/backends/tests/snapshots/test_temporal/test_temporal_literal_sql/mysql-timestamp/out.sql new file mode 100644 index 000000000000..09c9771b8a6b --- /dev/null +++ b/ibis/backends/tests/snapshots/test_temporal/test_temporal_literal_sql/mysql-timestamp/out.sql @@ -0,0 +1,2 @@ +SELECT + TIMESTAMP('2023-04-07T04:05:06.230136') AS `datetime.datetime(2023, 4, 7, 4, 5, 6, 230136)` \ No newline at end of file diff --git a/ibis/backends/tests/snapshots/test_temporal/test_time_literal_sql/0-mysql/out.sql b/ibis/backends/tests/snapshots/test_temporal/test_time_literal_sql/0-mysql/out.sql new file mode 100644 index 000000000000..9b09a5a59631 --- /dev/null +++ b/ibis/backends/tests/snapshots/test_temporal/test_time_literal_sql/0-mysql/out.sql @@ -0,0 +1,2 @@ +SELECT + MAKETIME(4, 5, 6.0) AS `datetime.time(4, 5, 6)` \ No newline at end of file diff --git a/ibis/backends/tests/snapshots/test_temporal/test_time_literal_sql/234567-mysql/out.sql b/ibis/backends/tests/snapshots/test_temporal/test_time_literal_sql/234567-mysql/out.sql new file mode 100644 index 000000000000..872832fdb485 --- /dev/null +++ b/ibis/backends/tests/snapshots/test_temporal/test_time_literal_sql/234567-mysql/out.sql @@ -0,0 +1,2 @@ +SELECT + MAKETIME(4, 5, 6.234567) AS `datetime.time(4, 5, 6, 234567)` \ No newline at end of file diff --git a/ibis/backends/tests/test_aggregation.py b/ibis/backends/tests/test_aggregation.py index dab220909ab1..bef645bc33ee 100644 --- a/ibis/backends/tests/test_aggregation.py +++ b/ibis/backends/tests/test_aggregation.py @@ -13,12 +13,13 @@ from ibis import _ from ibis import literal as L from ibis.backends.tests.errors import ( + ArrowInvalid, ClickHouseDatabaseError, DatabricksServerOperationError, ExaQueryError, GoogleBadRequest, ImpalaHiveServer2Error, - MySQLNotSupportedError, + MySQLProgrammingError, OracleDatabaseError, PolarsInvalidOperationError, PsycoPg2InternalError, @@ -1769,7 +1770,7 @@ def test_grouped_case(backend, con): @pytest.mark.notyet(["druid"], raises=PyDruidProgrammingError) @pytest.mark.notyet(["snowflake"], raises=SnowflakeProgrammingError) @pytest.mark.notyet(["trino"], raises=TrinoUserError) -@pytest.mark.notyet(["mysql"], raises=MySQLNotSupportedError) +@pytest.mark.notyet(["mysql"], raises=MySQLProgrammingError) @pytest.mark.notyet(["singlestoredb"], raises=SingleStoreDBOperationalError) @pytest.mark.notyet(["oracle"], raises=OracleDatabaseError) @pytest.mark.notyet(["pyspark"], raises=PySparkAnalysisException) @@ -1841,6 +1842,11 @@ def test_group_by_scalar(alltypes, df, value): assert n == len(df) +@pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_empty_sum(con): t = ibis.memtable({"x": [1]}, schema={"x": "int"}) result = con.execute(t.count()) diff --git a/ibis/backends/tests/test_array.py b/ibis/backends/tests/test_array.py index b41a83645f57..b4102e206f4c 100644 --- a/ibis/backends/tests/test_array.py +++ b/ibis/backends/tests/test_array.py @@ -22,6 +22,7 @@ DatabricksServerOperationError, GoogleBadRequest, MySQLOperationalError, + MySQLProgrammingError, PolarsComputeError, PsycoPg2IndeterminateDatatype, PsycoPg2InternalError, @@ -233,6 +234,7 @@ def test_array_index(con, idx): raises=( com.OperationNotDefinedError, MySQLOperationalError, + MySQLProgrammingError, SingleStoreDBProgrammingError, com.UnsupportedBackendType, com.TableNotFound, diff --git a/ibis/backends/tests/test_client.py b/ibis/backends/tests/test_client.py index 3161c5a303dd..c1d6aef622af 100644 --- a/ibis/backends/tests/test_client.py +++ b/ibis/backends/tests/test_client.py @@ -27,6 +27,7 @@ from ibis.backends.conftest import ALL_BACKENDS from ibis.backends.tests.conftest import NO_MERGE_SUPPORT from ibis.backends.tests.errors import ( + ArrowInvalid, ArrowTypeError, DatabricksServerOperationError, ExaQueryError, @@ -1317,6 +1318,11 @@ def test_interactive_repr_show_types(alltypes, show_types, monkeypatch): @pytest.mark.parametrize("is_jupyter", [True, False]) +@pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_interactive_repr_max_columns(alltypes, is_jupyter, monkeypatch): pytest.importorskip("rich") @@ -1984,6 +1990,12 @@ def test_insert_into_table_missing_columns(con, temp_table): reason="Memtables not visible in list_tables() due to transaction block restrictions.", # Related to: https://materialize.com/docs/sql/begin/ ) +@pytest.mark.notyet( + ["mysql"], + raises=AssertionError, + reason="ADBC get_objects doesn't see temporary tables", + # https://github.com/adbc-drivers/driverbase-go/issues/137 +) def test_memtable_cleanup(con): t = ibis.memtable({"a": [1, 2, 3], "b": list("def")}) @@ -2020,6 +2032,7 @@ def test_memtable_registered_exactly_once(con, mocker): spy.assert_called_once_with(t.op()) +@pytest.mark.filterwarnings("ignore::ResourceWarning") def test_stateful_data_is_loaded_once( con, data_dir, tmp_path_factory, worker_id, mocker ): diff --git a/ibis/backends/tests/test_export.py b/ibis/backends/tests/test_export.py index a1892c5bc961..4d1adf540409 100644 --- a/ibis/backends/tests/test_export.py +++ b/ibis/backends/tests/test_export.py @@ -15,7 +15,7 @@ DuckDBNotImplementedException, DuckDBParserException, ExaQueryError, - MySQLOperationalError, + MySQLProgrammingError, OracleDatabaseError, Py4JJavaError, PyAthenaOperationalError, @@ -104,6 +104,11 @@ def test_empty_column_to_pyarrow(limit, awards_players): @pytest.mark.parametrize("limit", no_limit) +@pytest.mark.notyet( + ["mysql"], + raises=pa.ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_empty_scalar_to_pyarrow(limit, awards_players): expr = awards_players.filter(awards_players.awardID == "DEADBEEF").yearID.sum() array = expr.to_pyarrow(limit=limit) @@ -111,6 +116,11 @@ def test_empty_scalar_to_pyarrow(limit, awards_players): @pytest.mark.parametrize("limit", no_limit) +@pytest.mark.notyet( + ["mysql"], + raises=pa.ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_scalar_to_pyarrow_scalar(limit, awards_players): scalar = awards_players.yearID.sum().to_pyarrow(limit=limit) assert isinstance(scalar, pa.Scalar) @@ -455,7 +465,7 @@ def test_table_to_csv_writer_kwargs(delimiter, tmp_path, awards_players): pytest.mark.notyet(["trino"], raises=TrinoUserError), pytest.mark.notyet(["athena"], raises=PyAthenaOperationalError), pytest.mark.notyet(["oracle"], raises=OracleDatabaseError), - pytest.mark.notyet(["mysql"], raises=MySQLOperationalError), + pytest.mark.notyet(["mysql"], raises=MySQLProgrammingError), pytest.mark.notyet( ["singlestoredb"], raises=SingleStoreDBOperationalError ), @@ -540,6 +550,11 @@ def test_roundtrip_delta(backend, con, alltypes, tmp_path, monkeypatch): ["databricks"], raises=AssertionError, reason="Only the devil knows" ) @pytest.mark.notyet(["athena"], raises=PyAthenaOperationalError) +@pytest.mark.notyet( + ["mysql"], + raises=pa.ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_arrow_timestamp_with_time_zone(alltypes): from ibis.formats.pyarrow import PyArrowType @@ -746,12 +761,22 @@ def test_all_null_column(con): ["snowflake", "bigquery", "databricks"], raises=pa.ArrowNotImplementedError ) @pytest.mark.notyet(["athena"], raises=PyAthenaOperationalError) +@pytest.mark.notyet( + ["mysql"], + raises=pa.ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_all_null_scalar(con): e = ibis.literal(None) result = con.to_pyarrow(e) assert pat.is_null(result.type) +@pytest.mark.notyet( + ["mysql"], + raises=pa.ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_cast_non_null(con): new_ids = ibis.memtable({"id": ["my_id"]}).cast({"id": "!string"}) assert not new_ids.schema()["id"].nullable diff --git a/ibis/backends/tests/test_generic.py b/ibis/backends/tests/test_generic.py index a6e6d2c43687..c9d73b9d9c9a 100644 --- a/ibis/backends/tests/test_generic.py +++ b/ibis/backends/tests/test_generic.py @@ -24,6 +24,7 @@ ExaQueryError, GoogleBadRequest, ImpalaHiveServer2Error, + MySQLOperationalError, MySQLProgrammingError, OracleDatabaseError, PolarsInvalidOperationError, @@ -1112,6 +1113,11 @@ def test_between(backend, alltypes, df): @pytest.mark.notyet(["flink"], reason="timestamp subtraction doesn't work") +@pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_interactive(alltypes, monkeypatch): monkeypatch.setattr(ibis.options, "interactive", True) @@ -1201,6 +1207,11 @@ def test_typeof(con): raises=PyODBCProgrammingError, reason="naked IN queries are not supported", ) +@pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_isin_uncorrelated_simple(con): u1 = ibis.memtable({"id": [1, 2, 3]}) a = ibis.memtable({"id": [1, 2]}) @@ -1406,7 +1417,7 @@ def test_memtable_column_naming_mismatch(con, monkeypatch, df, columns): @pytest.mark.notyet( - ["mssql", "mysql", "exasol", "impala"], + ["mssql", "exasol", "impala"], reason="various syntax errors reported", ) @pytest.mark.notyet( @@ -2635,6 +2646,11 @@ def test_select_sort_sort_deferred(backend, alltypes, df): raises=AttributeError, reason="not yet added the data for this backend", ) +@pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_topk_counts_null(con): t = con.tables.topk tk = t.x.topk(10) @@ -2818,6 +2834,11 @@ def test_table_describe_with_multiple_decimal_columns(con): sqlite3.InterfaceError, ), ) +@pytest.mark.notyet( + ["mysql"], + raises=(MySQLOperationalError, OSError), + reason="ADBC MySQL driver maps DECIMAL(n,0) to int64 but fails to parse the text-protocol value; see https://github.com/adbc-drivers/driverbase-go/issues/129", +) def test_comparison_with_decimal_literal(con): t = ibis.memtable( {"a": [decimal.Decimal(1), decimal.Decimal(2), decimal.Decimal(3)]} diff --git a/ibis/backends/tests/test_interactive.py b/ibis/backends/tests/test_interactive.py index 1488f9684e82..3ad83d02ba1b 100644 --- a/ibis/backends/tests/test_interactive.py +++ b/ibis/backends/tests/test_interactive.py @@ -19,6 +19,7 @@ import ibis import ibis.common.exceptions as exc +from ibis.backends.tests.errors import ArrowInvalid @pytest.fixture @@ -36,6 +37,11 @@ def table(backend): @pytest.mark.notimpl(["polars"]) +@pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_interactive_execute_on_repr(table, queries): repr(table.bigint_col.sum()) assert len(queries) >= 1 @@ -56,6 +62,11 @@ def test_repr_png_is_not_none_in_not_interactive(table, monkeypatch): @pytest.mark.notimpl(["polars"]) +@pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_default_limit(table, queries): repr(table.select("id", "bool_col")) @@ -63,6 +74,11 @@ def test_default_limit(table, queries): @pytest.mark.notimpl(["polars"]) +@pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_respect_set_limit(table, queries): repr(table.select("id", "bool_col").limit(10)) @@ -70,6 +86,11 @@ def test_respect_set_limit(table, queries): @pytest.mark.notimpl(["polars"]) +@pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_disable_query_limit(table, queries, monkeypatch): assert ibis.options.sql.default_limit is None diff --git a/ibis/backends/tests/test_join.py b/ibis/backends/tests/test_join.py index 46d71d3bc737..8f5c9e6b701b 100644 --- a/ibis/backends/tests/test_join.py +++ b/ibis/backends/tests/test_join.py @@ -9,6 +9,7 @@ import ibis import ibis.common.exceptions as com import ibis.expr.schema as sch +from ibis.backends.tests.errors import ArrowInvalid np = pytest.importorskip("numpy") pa = pytest.importorskip("pyarrow") @@ -289,6 +290,13 @@ def test_join_with_trivial_predicate(awards_players, predicate, how, pandas_valu lambda left: left.filter(lambda t: t.x == 1).select(y=lambda t: t.x), [("x", "y")], id="left-xy", + marks=[ + pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", + ), + ], ), param( "left", @@ -296,6 +304,13 @@ def test_join_with_trivial_predicate(awards_players, predicate, how, pandas_valu lambda left: left.filter(lambda t: t.x == 1), "x", id="left-x", + marks=[ + pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", + ), + ], ), param( "right", @@ -303,7 +318,14 @@ def test_join_with_trivial_predicate(awards_players, predicate, how, pandas_valu lambda left: left.filter(lambda t: t.x == 1).select(y=lambda t: t.x), [("x", "y")], id="right-xy", - marks=[sqlite_right_or_full_mark], + marks=[ + sqlite_right_or_full_mark, + pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", + ), + ], ), param( "right", @@ -311,7 +333,14 @@ def test_join_with_trivial_predicate(awards_players, predicate, how, pandas_valu lambda left: left.filter(lambda t: t.x == 1), "x", id="right-x", - marks=[sqlite_right_or_full_mark], + marks=[ + sqlite_right_or_full_mark, + pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", + ), + ], ), param( "outer", diff --git a/ibis/backends/tests/test_numeric.py b/ibis/backends/tests/test_numeric.py index 2f511bbcb281..7234961614cd 100644 --- a/ibis/backends/tests/test_numeric.py +++ b/ibis/backends/tests/test_numeric.py @@ -21,6 +21,7 @@ GoogleBadRequest, ImpalaHiveServer2Error, MySQLOperationalError, + MySQLProgrammingError, OracleDatabaseError, PsycoPg2InternalError, PsycoPgDivisionByZero, @@ -324,6 +325,11 @@ def test_numeric_literal(con, backend, expr, expected_types): reason="precision must be specified; clickhouse doesn't have a default", raises=NotImplementedError, ), + pytest.mark.notyet( + ["mysql"], + raises=(MySQLOperationalError, OSError), + reason="ADBC MySQL driver maps DECIMAL(n,0) to int64 but fails to parse the text-protocol value; see https://github.com/adbc-drivers/driverbase-go/issues/129", + ), ], id="default", ), @@ -399,7 +405,7 @@ def test_numeric_literal(con, backend, expr, expected_types): }, marks=[ pytest.mark.notimpl(["exasol"], raises=ExaQueryError), - pytest.mark.notimpl(["mysql"], raises=MySQLOperationalError), + pytest.mark.notimpl(["mysql"], raises=MySQLProgrammingError), pytest.mark.notimpl( ["singlestoredb"], raises=SingleStoreDBOperationalError ), @@ -762,7 +768,10 @@ def test_decimal_literal(con, backend, expr, expected_types, expected_result): @pytest.mark.notimpl( ["flink"], raises=(com.OperationNotDefinedError, NotImplementedError) ) -@pytest.mark.notimpl(["mysql"], raises=(MySQLOperationalError, NotImplementedError)) +@pytest.mark.notimpl( + ["mysql"], + raises=(MySQLOperationalError, MySQLProgrammingError, NotImplementedError), +) @pytest.mark.notimpl( ["singlestoredb"], raises=(SingleStoreDBOperationalError, NotImplementedError) ) @@ -1646,6 +1655,11 @@ def test_bitwise_scalars(con, op, left, right): reason="Streaming database does not guarantee row order without ORDER BY", strict=False, ) +@pytest.mark.notyet( + ["mysql"], + raises=AssertionError, + reason="ADBC MySQL driver returns UNSIGNED BIGINT as opaque extension type; fixed upstream in https://github.com/adbc-drivers/mysql/pull/80", +) @flink_no_bitwise def test_bitwise_not_scalar(con): expr = ~L(2) @@ -1662,6 +1676,11 @@ def test_bitwise_not_scalar(con): reason="Streaming database does not guarantee row order without ORDER BY", strict=False, ) +@pytest.mark.notyet( + ["mysql"], + raises=AssertionError, + reason="ADBC MySQL driver returns UNSIGNED BIGINT as opaque extension type; fixed upstream in https://github.com/adbc-drivers/mysql/pull/80", +) @flink_no_bitwise def test_bitwise_not_col(backend, alltypes, df): expr = (~alltypes.int_col).name("tmp") diff --git a/ibis/backends/tests/test_temporal.py b/ibis/backends/tests/test_temporal.py index 45cf35a8dc90..0e525a5cac57 100644 --- a/ibis/backends/tests/test_temporal.py +++ b/ibis/backends/tests/test_temporal.py @@ -2140,7 +2140,11 @@ def test_large_timestamp(con): pytest.mark.notyet( ["mysql", "singlestoredb"], reason="doesn't support nanoseconds", - raises=(MySQLOperationalError, SingleStoreDBOperationalError), + raises=( + MySQLOperationalError, + MySQLProgrammingError, + SingleStoreDBOperationalError, + ), ), pytest.mark.notyet( ["bigquery"], diff --git a/ibis/backends/tests/test_window.py b/ibis/backends/tests/test_window.py index 14e7f7ee54ff..6e27544a3e28 100644 --- a/ibis/backends/tests/test_window.py +++ b/ibis/backends/tests/test_window.py @@ -10,10 +10,12 @@ import ibis.common.exceptions as com import ibis.expr.datatypes as dt from ibis.backends.tests.errors import ( + ArrowInvalid, ClickHouseDatabaseError, GoogleBadRequest, ImpalaHiveServer2Error, MySQLOperationalError, + MySQLProgrammingError, PsycoPg2InternalError, Py4JJavaError, PyDruidProgrammingError, @@ -969,7 +971,7 @@ def test_ungrouped_unbounded_window( @pytest.mark.notyet(["mssql"], raises=PyODBCProgrammingError) @pytest.mark.notyet( ["mysql"], - raises=MySQLOperationalError, + raises=(MySQLOperationalError, MySQLProgrammingError), reason="https://github.com/tobymao/sqlglot/issues/2779", ) @pytest.mark.notyet( @@ -1151,7 +1153,7 @@ def test_first_last(backend): ["impala"], raises=ImpalaHiveServer2Error, reason="not supported by Impala" ) @pytest.mark.notyet( - ["mysql"], raises=MySQLOperationalError, reason="not supported by MySQL" + ["mysql"], raises=MySQLProgrammingError, reason="not supported by MySQL" ) @pytest.mark.notyet( ["singlestoredb"], @@ -1298,6 +1300,11 @@ def test_windowed_order_by_sequence_is_preserved(con): raises=PsycoPg2InternalError, reason="Window function with empty PARTITION BY is not supported due to performance issues", ) +@pytest.mark.notyet( + ["mysql"], + raises=ArrowInvalid, + reason="ADBC MySQL driver returns opaque type for NULL", +) def test_duplicate_ordered_sum(con): expr = ( ibis.memtable( diff --git a/pyproject.toml b/pyproject.toml index b1890f7057cd..107533d2789f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -167,7 +167,7 @@ mssql = [ "rich>=12.4.4", ] mysql = [ - "mysqlclient>=2.2.4", + "adbc-driver-manager>=0.11.0", "pyarrow>=10.0.1", "pyarrow-hotfix>=0.4", "numpy>=1.23.2,<3", diff --git a/requirements-dev.txt b/requirements-dev.txt index 4cecf63075ea..7c961b556475 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ -e . +adbc-driver-manager==1.10.0 aiobotocore==3.1.1 aiohappyeyeballs==2.6.1 aiohttp==3.13.3 @@ -145,7 +146,6 @@ mistune==3.2.0 mizani==0.14.4 multidict==6.7.1 mypy-extensions==1.1.0 -mysqlclient==2.2.7 narwhals==2.16.0 nbclient==0.10.4 nbconvert==7.17.0 diff --git a/uv.lock b/uv.lock index be44acb8cc57..d1dac1623fd6 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,51 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "adbc-driver-manager" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/77/b6ffd112a67d133810d0027e9de4408a6e63e0e1c438f5866cc28eb3c213/adbc_driver_manager-1.10.0.tar.gz", hash = "sha256:f04407cf2f99bfde13dea0e136d87219c8a16678d43e322744dbd84cdd8eaac2", size = 208204, upload-time = "2026-01-09T07:13:45.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/95eae266a8d97f2f222e6db9047dc4c1fab6a3e1d5e6bd9c8efb29881ec4/adbc_driver_manager-1.10.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b82d7ffab5ad4c892e2f3201cc3781db3f87ef0c5ce1938715fb39a5dc6671b0", size = 532926, upload-time = "2026-01-09T07:11:52.672Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7c/c7234fe0e25ccd0fe23d8fa1e3f2682d407f49916e845e15869d262fc648/adbc_driver_manager-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e124ad209bc7112d0c0778fcc2e727c4fdf733188403129a82c10e563e89252b", size = 513090, upload-time = "2026-01-09T07:11:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/8d/81/6fb0075c67d1039e82960ab9d039da00ef3149b872a067d2e83ea9bb9956/adbc_driver_manager-1.10.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0abafd6b7d8ef5ba9c33fa92a1c5c329bfb89a68fb12e88ca62a4e32d822f257", size = 3039894, upload-time = "2026-01-09T07:11:56.892Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/10e2abe7c600545fcf5b684b04073b36c87ed879a4bbc8fcd4f6f329c302/adbc_driver_manager-1.10.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ceca0800974137d2373cfb3aa4862af4b9361a2e5b94808b52df63c3f34a14eb", size = 3053785, upload-time = "2026-01-09T07:11:59.051Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dd/8f0fe60d49fe0b7bd9eb0b76268d662f95b31a8c623fc7cef40ad9488d0f/adbc_driver_manager-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:23504672daeafabe03d4e07038754910f55f6845ef260f2249d9d8942ab16866", size = 714987, upload-time = "2026-01-09T07:12:00.771Z" }, + { url = "https://files.pythonhosted.org/packages/bd/23/eaea050e76a1f65749be243a68514d67e13ab896c47cbf9e652da0ba9c10/adbc_driver_manager-1.10.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:715a33d750af09e1c03fde1783490c816e08a786f151ac79269659da1d2cc4e0", size = 533268, upload-time = "2026-01-09T07:12:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/b81d64da4b1a032df0798bbf8c2e3abf875f9dd319598308d2efebe06523/adbc_driver_manager-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd40c9b20be54c55b3ce64cabd5f35f29a61886574d990a1d5b5bdd7f81a7b6", size = 513190, upload-time = "2026-01-09T07:12:04.025Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/a03cd7d4eb81c478566a38e6a657b83171e61e84f6aa0c0f9b49ae9d498c/adbc_driver_manager-1.10.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:595ab4a8ec2ddb338c70f3c31481a41830ad9e2d8c1a1884184023303098bc92", size = 3111408, upload-time = "2026-01-09T07:12:06.421Z" }, + { url = "https://files.pythonhosted.org/packages/97/67/b9309e5351d4ff02720719c6ca01716ded33075fa486157db409bc5f47be/adbc_driver_manager-1.10.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92fdf3247aef506583e79b3b583c1bf93f28c70e771281a41843aba63c61f732", size = 3124914, upload-time = "2026-01-09T07:12:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/41/1d/228041cc7ee30e51556d991d5f30981bfbf0c2d2a91c83f34ace2a2a9d2c/adbc_driver_manager-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:7c5becb5a81fae563a10d82b570c4e1c7a8994c5b110ddaaae6afa9fd52a17b6", size = 716182, upload-time = "2026-01-09T07:12:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/3f/54/deedd6a3fd222ed80ee3441371fdfbd315014a090fe7faf068b1463af7ec/adbc_driver_manager-1.10.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:0f8133130271aff2744a5f706bdb7ec91aab14c19d6db45edd9ccd70e08d778b", size = 532164, upload-time = "2026-01-09T07:12:11.698Z" }, + { url = "https://files.pythonhosted.org/packages/d7/05/0d65aa46491924beff32507aa39956eea68522095b2d67af0ad0461730df/adbc_driver_manager-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a8862ed1825df0bbaf9ab4353addd06e1dc4d53f708fb1f4be1fb86e1d91d3f4", size = 509565, upload-time = "2026-01-09T07:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a1/60cf47f45d09db6d2d0a83fb58307cccf0d6e3f63f007ee5f5b1ef893471/adbc_driver_manager-1.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24fca9fd6435ad4cfa1f3e125add6b964bb8be2102b518cf2e096e317cbc66bd", size = 3100269, upload-time = "2026-01-09T07:12:16.035Z" }, + { url = "https://files.pythonhosted.org/packages/93/32/6ca400dc7aaf885d823b37884e40832ccf970df043f5d2817f5eb651f3bc/adbc_driver_manager-1.10.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b112890d3c214380857852eaac40d09e66bd77ce3b75406094f2e48452e57bbd", size = 3130371, upload-time = "2026-01-09T07:12:18.295Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/629132ae0f317755d22138df0c23ce448c98f2848bdf06e846d72ea0e10e/adbc_driver_manager-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:189d78787d4d77a25f946b9b96139320d53e24ecb43f39a7fb21873c5f9ce874", size = 706573, upload-time = "2026-01-09T07:12:20.003Z" }, + { url = "https://files.pythonhosted.org/packages/ab/da/121d46b2ddf87f7589ca52ca92585b12618ab8493d9980546e42976b1225/adbc_driver_manager-1.10.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:dfba1775474e2ecf8af7486fccd6471811f2e5d02c2dc25c0e3e955a7e9a0e15", size = 529587, upload-time = "2026-01-09T07:12:21.546Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9c/6f9929b53cd578bef06b8d000e0ab829b982bcf5b22a6c99acfbad2aab34/adbc_driver_manager-1.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:94cc8b279c90c66f60a499996651340c17eb40d2fd7ad22e1fe73969ab4db1ee", size = 507669, upload-time = "2026-01-09T07:12:22.88Z" }, + { url = "https://files.pythonhosted.org/packages/52/7b/2c076500e60cac3c2761eeecc82afed42af22d3a65cf3cd8d8034ffd75ad/adbc_driver_manager-1.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ae24386989dfa055a09c800d13d5278d5d0399aee2548f071f414e6b8af63fc8", size = 3093831, upload-time = "2026-01-09T07:12:25.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/3e131221995aef7edfd4dd0b09f14b7e51772d28eb362a0e6c3b8301a22a/adbc_driver_manager-1.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97e06da4235dabbd29244c8bd83f769c8995c25abed5d0c2ee2d95ec76d48b8a", size = 3116517, upload-time = "2026-01-09T07:12:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/97/c2/2ed6c856dd56bbc0a45aaab67f6b1f0a846296f20d5ad625a3c5e7084e4f/adbc_driver_manager-1.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:564a95617bda8907a0ad0a8bc8fea0c2cf951cea747c0d750a4b1740c828b1ef", size = 705122, upload-time = "2026-01-09T07:12:35.999Z" }, + { url = "https://files.pythonhosted.org/packages/36/f5/0e6518feac552081f523dbd886670ebb8210b065bdf97ea1e6af7113f1b5/adbc_driver_manager-1.10.0-cp313-cp313t-macosx_10_15_x86_64.whl", hash = "sha256:fcb5fc9dbf737341eaa28ca2c529c8432dc24aa282cad5a68fc31e5ddd9373fe", size = 546640, upload-time = "2026-01-09T07:12:28.115Z" }, + { url = "https://files.pythonhosted.org/packages/ed/40/e79cce0101eaf482519d39d69811983f084aeb4f2c1d76f9e98301f41f39/adbc_driver_manager-1.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fd42acb06a5bb5683b583ebad320f31f9e403f5395e6272e44aab0e379349aeb", size = 526724, upload-time = "2026-01-09T07:12:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/5149308e6a5f38f4995ee4d0d809ed57f7d2c86eb0c358eff3445cf64fca/adbc_driver_manager-1.10.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e05838151ce926f38b2cfe016a2007af9e20148fb7bfa9025524a319f1f0aca", size = 3149413, upload-time = "2026-01-09T07:12:31.814Z" }, + { url = "https://files.pythonhosted.org/packages/74/92/ab9b0f3e90b9140f48dc812b81be3ec54191908281f78c2142094098633e/adbc_driver_manager-1.10.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71d78d7dec6b0a69023408931c13c326f3226d6977526c605afde417f883e0ed", size = 3137409, upload-time = "2026-01-09T07:12:33.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/1a/3d3e1da53e7a06fdbe9d3a4baf3fb603a8c44d38b7898c7cf2fdd39f5b0b/adbc_driver_manager-1.10.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7ae144f60c1167c049af3fe7ee9231502b49eb6b7eed3c0f441e001cef841c8c", size = 530253, upload-time = "2026-01-09T07:12:37.73Z" }, + { url = "https://files.pythonhosted.org/packages/16/07/67239506bfe9e52904e97f4908911393a751430bce123ccd35e947564f08/adbc_driver_manager-1.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0ef746fe4579238f690bd5907c631f7c2e79a6e681c79871bf30f4552de0203", size = 510023, upload-time = "2026-01-09T07:12:40.172Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/2a480611bc4959fc8227f941a76a2deb3c43900a1e94588fde04bdf43ca2/adbc_driver_manager-1.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8404b88bbce61c0133fa1cd3fa292cef631d36811028d9fd297c1abc1c6f357f", size = 3084610, upload-time = "2026-01-09T07:12:42.563Z" }, + { url = "https://files.pythonhosted.org/packages/94/83/dd3adedf868d5a1a35268f3fa9a4c497083e3464d1919b2486eda60561e5/adbc_driver_manager-1.10.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13123311a6ef468a211a4c8b48bbb60bbbf56283ad56d403bdffb2b087d70e0c", size = 3099721, upload-time = "2026-01-09T07:12:44.824Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1c/787c51fac725e5763e79ce4e22a4b067a8ad97330d915501a89c7e5bfded/adbc_driver_manager-1.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:0ccb5a1e387ed68ac044b7de674185e2f14cffe636294a453e55f22b70bdc709", size = 723075, upload-time = "2026-01-09T07:12:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/b103f90a2cedc61dc17065dfcfc6d3f4ab0ebac4c6ad739334be03daaf89/adbc_driver_manager-1.10.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fb2d02dfdeb2f8c63f168ca04c48395545a56b91b70027b42913dfa48401bcca", size = 547043, upload-time = "2026-01-09T07:12:46.199Z" }, + { url = "https://files.pythonhosted.org/packages/4e/58/ae2ac9dee3fae5c4fe8a04513c8386257aa3e6e332a1e2697f4f11525b01/adbc_driver_manager-1.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1af42749bb377341e2d4ae5f2d7884e61607f69dd0ba555251917d7e570af6a", size = 527444, upload-time = "2026-01-09T07:12:47.895Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/5d44c86e150664a6a2d1cd9ad1f79e80ad7953783342c5ac81b70d9d1513/adbc_driver_manager-1.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e2119add50c528601a1089fe6da63d7d3e41c611db85ced053a70fc1b9b100d", size = 3149149, upload-time = "2026-01-09T07:12:49.923Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ec/e835243c6590397f7c8e4041effeec7e5929f54aa28456364c1fb10e3c11/adbc_driver_manager-1.10.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d26bc156f0b7d8429572e7ea52830f73049de1626ae44778a915f5a88fd591b", size = 3139215, upload-time = "2026-01-09T07:12:52.046Z" }, + { url = "https://files.pythonhosted.org/packages/8c/32/5925fbaa8368ca943e6776c9d08b5b9e5e093069f7c84b74c690bfbde734/adbc_driver_manager-1.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c9233443ad140ba12ddc719a4e8dab485e6bbdc9ebbd3babbc88d5b50133960c", size = 763620, upload-time = "2026-01-09T07:12:53.315Z" }, +] + [[package]] name = "aiobotocore" version = "3.1.1" @@ -2590,7 +2635,7 @@ mssql = [ { name = "rich" }, ] mysql = [ - { name = "mysqlclient" }, + { name = "adbc-driver-manager" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pandas" }, @@ -2745,6 +2790,7 @@ tests = [ [package.metadata] requires-dist = [ + { name = "adbc-driver-manager", marker = "extra == 'mysql'", specifier = ">=0.11.0" }, { name = "atpublic", specifier = ">=2.3" }, { name = "black", marker = "extra == 'decompiler'", specifier = ">=22.1.0" }, { name = "clickhouse-connect", extras = ["arrow", "pandas", "numpy"], marker = "extra == 'clickhouse'", specifier = ">=0.5.23" }, @@ -2760,7 +2806,6 @@ requires-dist = [ { name = "google-cloud-bigquery-storage", marker = "extra == 'bigquery'", specifier = ">=2" }, { name = "graphviz", marker = "extra == 'visualization'", specifier = ">=0.16" }, { name = "impyla", marker = "extra == 'impala'", specifier = ">=0.17" }, - { name = "mysqlclient", marker = "extra == 'mysql'", specifier = ">=2.2.4" }, { name = "numpy", marker = "extra == 'athena'", specifier = ">=1.23.2,<3" }, { name = "numpy", marker = "extra == 'bigquery'", specifier = ">=1.23.2,<3" }, { name = "numpy", marker = "extra == 'clickhouse'", specifier = ">=1.23.2,<3" }, @@ -4034,19 +4079,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "mysqlclient" -version = "2.2.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/68/810093cb579daae426794bbd9d88aa830fae296e85172d18cb0f0e5dd4bc/mysqlclient-2.2.7.tar.gz", hash = "sha256:24ae22b59416d5fcce7e99c9d37548350b4565baac82f95e149cac6ce4163845", size = 91383, upload-time = "2025-01-10T12:06:00.763Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/24/cdaaef42aac7d53c0a01bb638da64961c293b1b6d204efd47400a68029d4/mysqlclient-2.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:2e3c11f7625029d7276ca506f8960a7fd3c5a0a0122c9e7404e6a8fe961b3d22", size = 207748, upload-time = "2025-01-10T11:56:24.357Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3e2de3f93cd60dd63bd229ec3e3b679f682982614bf513d046c2722aa4ce/mysqlclient-2.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687", size = 207745, upload-time = "2025-01-10T11:56:28.67Z" }, - { url = "https://files.pythonhosted.org/packages/bb/b5/2a8a4bcba3440550f358b839638fe8ec9146fa3c9194890b4998a530c926/mysqlclient-2.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:4b4c0200890837fc64014cc938ef2273252ab544c1b12a6c1d674c23943f3f2e", size = 208032, upload-time = "2025-01-10T11:56:29.879Z" }, - { url = "https://files.pythonhosted.org/packages/29/01/e80141f1cd0459e4c9a5dd309dee135bbae41d6c6c121252fdd853001a8a/mysqlclient-2.2.7-cp313-cp313-win_amd64.whl", hash = "sha256:201a6faa301011dd07bca6b651fe5aaa546d7c9a5426835a06c3172e1056a3c5", size = 208000, upload-time = "2025-01-10T11:56:32.293Z" }, - { url = "https://files.pythonhosted.org/packages/16/cc/5b1570be9f8597ee41e2a0bd7b62ba861ec2c81898d9449f3d6bfbe15d29/mysqlclient-2.2.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:92af368ed9c9144737af569c86d3b6c74a012a6f6b792eb868384787b52bb585", size = 207800, upload-time = "2025-01-10T11:56:36.023Z" }, -] - [[package]] name = "narwhals" version = "2.16.0"