초기화
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
@@ -0,0 +1,43 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""GenAI package for MySQL Connector/Python.
|
||||
|
||||
Performs optional dependency checks and exposes public classes:
|
||||
- MyEmbeddings
|
||||
- MyLLM
|
||||
- MyVectorStore
|
||||
"""
|
||||
from mysql.ai.utils import check_dependencies as _check_dependencies
|
||||
|
||||
_check_dependencies(["GENAI"])
|
||||
del _check_dependencies
|
||||
|
||||
from .embedding import MyEmbeddings
|
||||
from .generation import MyLLM
|
||||
from .vector_store import MyVectorStore
|
||||
@@ -0,0 +1,197 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Embeddings integration utilities for MySQL Connector/Python.
|
||||
|
||||
Provides MyEmbeddings class to generate embeddings via MySQL HeatWave
|
||||
using ML_EMBED_TABLE and ML_EMBED_ROW.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from langchain_core.embeddings import Embeddings
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from mysql.ai.utils import (
|
||||
atomic_transaction,
|
||||
execute_sql,
|
||||
format_value_sql,
|
||||
source_schema,
|
||||
sql_table_from_df,
|
||||
sql_table_to_df,
|
||||
temporary_sql_tables,
|
||||
)
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
class MyEmbeddings(Embeddings):
|
||||
"""
|
||||
Embedding generator class that uses a MySQL database to compute embeddings for input text.
|
||||
|
||||
This class batches input text into temporary SQL tables, invokes MySQL's ML_EMBED_TABLE
|
||||
to generate embeddings, and retrieves the results as lists of floats.
|
||||
|
||||
Attributes:
|
||||
_db_connection (MySQLConnectionAbstract): MySQL connection used for all database operations.
|
||||
schema_name (str): Name of the database schema to use.
|
||||
options_placeholder (str): SQL-ready placeholder string for ML_EMBED_TABLE options.
|
||||
options_params (dict): Dictionary of concrete option values to be passed as SQL parameters.
|
||||
"""
|
||||
|
||||
_db_connection: MySQLConnectionAbstract = PrivateAttr()
|
||||
|
||||
def __init__(
|
||||
self, db_connection: MySQLConnectionAbstract, options: Optional[Dict] = None
|
||||
):
|
||||
"""
|
||||
Initialize MyEmbeddings with a database connection and optional embedding parameters.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwgenai-ml-embed-row.html
|
||||
A full list of supported options can be found under "options"
|
||||
|
||||
NOTE: The supported "options" are the intersection of the options provided in
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwgenai-ml-embed-row.html
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwgenai-ml-embed-table.html
|
||||
|
||||
Args:
|
||||
db_connection: Active MySQL connector database connection.
|
||||
options: Optional dictionary of options for embedding operations.
|
||||
|
||||
Raises:
|
||||
ValueError: If the schema name is not valid
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
super().__init__()
|
||||
self._db_connection = db_connection
|
||||
self.schema_name = source_schema(db_connection)
|
||||
options = options or {}
|
||||
self.options_placeholder, self.options_params = format_value_sql(options)
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
"""
|
||||
Generate embeddings for a list of input texts using the MySQL ML embedding procedure.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwgenai-ml-embed-table.html
|
||||
|
||||
Args:
|
||||
texts: List of input strings to embed.
|
||||
|
||||
Returns:
|
||||
List of lists of floats, with each inner list containing the embedding for a text.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported.
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError:
|
||||
If one or more text entries were unable to be embedded.
|
||||
|
||||
Implementation notes:
|
||||
- Creates a temporary table to pass input text to the MySQL embedding service.
|
||||
- Adds a primary key to ensure results preserve input order.
|
||||
- Calls ML_EMBED_TABLE and fetches the resulting embeddings.
|
||||
- Deletes the temporary table after use to avoid polluting the database.
|
||||
- Embedding vectors are extracted from the "embeddings" column of the result table.
|
||||
"""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
df = pd.DataFrame({"id": range(len(texts)), "text": texts})
|
||||
|
||||
with (
|
||||
atomic_transaction(self._db_connection) as cursor,
|
||||
temporary_sql_tables(self._db_connection) as temporary_tables,
|
||||
):
|
||||
qualified_table_name, table_name = sql_table_from_df(
|
||||
cursor, self.schema_name, df
|
||||
)
|
||||
temporary_tables.append((self.schema_name, table_name))
|
||||
|
||||
# ML_EMBED_TABLE expects input/output columns and options as parameters
|
||||
embed_query = (
|
||||
"CALL sys.ML_EMBED_TABLE("
|
||||
f"'{qualified_table_name}.text', "
|
||||
f"'{qualified_table_name}.embeddings', "
|
||||
f"{self.options_placeholder}"
|
||||
")"
|
||||
)
|
||||
execute_sql(cursor, embed_query, params=self.options_params)
|
||||
|
||||
# Read back all columns, including "embeddings"
|
||||
df_embeddings = sql_table_to_df(cursor, self.schema_name, table_name)
|
||||
|
||||
if df_embeddings["embeddings"].isnull().any() or any(
|
||||
e is None for e in df_embeddings["embeddings"]
|
||||
):
|
||||
raise ValueError(
|
||||
"Failure to generate embeddings for one or more text entry."
|
||||
)
|
||||
|
||||
# Convert fetched embeddings to lists of floats
|
||||
embeddings = df_embeddings["embeddings"].tolist()
|
||||
embeddings = [list(e) for e in embeddings]
|
||||
|
||||
return embeddings
|
||||
|
||||
def embed_query(self, text: str) -> List[float]:
|
||||
"""
|
||||
Generate an embedding for a single text string.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwgenai-ml-embed-row.html
|
||||
|
||||
Args:
|
||||
text: The input string to embed.
|
||||
|
||||
Returns:
|
||||
List of floats representing the embedding vector.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported.
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Example:
|
||||
>>> MyEmbeddings(db_conn).embed_query("Hello world")
|
||||
[0.1, 0.2, ...]
|
||||
"""
|
||||
with atomic_transaction(self._db_connection) as cursor:
|
||||
execute_sql(
|
||||
cursor,
|
||||
f'SELECT sys.ML_EMBED_ROW("%s", {self.options_placeholder})',
|
||||
params=(text, *self.options_params),
|
||||
)
|
||||
return list(cursor.fetchone()[0])
|
||||
@@ -0,0 +1,162 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""GenAI LLM integration utilities for MySQL Connector/Python.
|
||||
|
||||
Provides MyLLM wrapper that issues ML_GENERATE calls via SQL.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from typing import Any, List, Optional
|
||||
|
||||
try:
|
||||
from langchain_core.language_models.llms import LLM
|
||||
except ImportError:
|
||||
from langchain.llms.base import LLM
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from mysql.ai.utils import atomic_transaction, execute_sql, format_value_sql
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
class MyLLM(LLM):
|
||||
"""
|
||||
Custom Large Language Model (LLM) interface for MySQL HeatWave.
|
||||
|
||||
This class wraps the generation functionality provided by HeatWave LLMs,
|
||||
exposing an interface compatible with common LLM APIs for text generation.
|
||||
It provides full support for generative queries and limited support for
|
||||
agentic queries.
|
||||
|
||||
Attributes:
|
||||
_db_connection (MySQLConnectionAbstract):
|
||||
Underlying MySQL connector database connection.
|
||||
"""
|
||||
|
||||
_db_connection: MySQLConnectionAbstract = PrivateAttr()
|
||||
|
||||
class Config:
|
||||
"""
|
||||
Pydantic config for the model.
|
||||
|
||||
By default, LangChain (through Pydantic BaseModel) does not allow
|
||||
setting or storing undeclared attributes such as _db_connection.
|
||||
Setting extra = "allow" makes it possible to store extra attributes
|
||||
on the class instance, which is required for MyLLM.
|
||||
"""
|
||||
|
||||
extra = "allow"
|
||||
|
||||
def __init__(self, db_connection: MySQLConnectionAbstract):
|
||||
"""
|
||||
Initialize the MyLLM instance with an active MySQL database connection.
|
||||
|
||||
Args:
|
||||
db_connection: A MySQL connection object used to run LLM queries.
|
||||
|
||||
Notes:
|
||||
The db_connection is stored as a private attribute via object.__setattr__,
|
||||
which is compatible with Pydantic models.
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
self._db_connection = db_connection
|
||||
|
||||
def _call(
|
||||
self,
|
||||
prompt: str,
|
||||
stop: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a text completion from the LLM for a given input prompt.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwgenai-ml-generate.html
|
||||
A full list of supported options (specified by kwargs) can be found under "options"
|
||||
|
||||
Args:
|
||||
prompt: The input prompt string for the language model.
|
||||
stop: Optional list of stop strings to support agentic and chain-of-thought
|
||||
reasoning workflows.
|
||||
**kwargs: Additional keyword arguments providing generation options to
|
||||
the LLM (these are serialized to JSON and passed to the HeatWave syscall).
|
||||
|
||||
Returns:
|
||||
The generated model output as a string.
|
||||
(The actual completion does NOT include the input prompt.)
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported.
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Implementation Notes:
|
||||
- Serializes kwargs into a SQL-compatible JSON string.
|
||||
- Calls the LLM stored procedure using a database cursor context.
|
||||
- Uses `sys.ML_GENERATE` on the server to produce the model output.
|
||||
- Expects the server response to be a JSON object with a 'text' key.
|
||||
"""
|
||||
options = kwargs.copy()
|
||||
if stop is not None:
|
||||
options["stop_sequences"] = stop
|
||||
|
||||
options_placeholder, options_params = format_value_sql(options)
|
||||
with atomic_transaction(self._db_connection) as cursor:
|
||||
# The prompt is passed as a parameterized argument (avoids SQL injection).
|
||||
generate_query = f"""SELECT sys.ML_GENERATE("%s", {options_placeholder});"""
|
||||
execute_sql(cursor, generate_query, params=(prompt, *options_params))
|
||||
# Expect a JSON-encoded result from MySQL; parse to extract the output.
|
||||
llm_response = json.loads(cursor.fetchone()[0])["text"]
|
||||
|
||||
return llm_response
|
||||
|
||||
@property
|
||||
def _identifying_params(self) -> dict:
|
||||
"""
|
||||
Return a dictionary of params that uniquely identify this LLM instance.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary of identifier parameters (should include
|
||||
model_name for tracing/caching).
|
||||
"""
|
||||
return {
|
||||
"model_name": "mysql_heatwave_llm",
|
||||
}
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
"""
|
||||
Get the type name of this LLM implementation.
|
||||
|
||||
Returns:
|
||||
A string identifying the LLM provider (used for logging or metrics).
|
||||
"""
|
||||
return "mysql_heatwave_llm"
|
||||
@@ -0,0 +1,520 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""MySQL-backed vector store for embeddings and semantic document retrieval.
|
||||
|
||||
Provides a VectorStore implementation persisting documents, metadata, and
|
||||
embeddings in MySQL, plus similarity search utilities.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Union
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.embeddings import Embeddings
|
||||
from langchain_core.vectorstores import VectorStore
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from mysql.ai.genai.embedding import MyEmbeddings
|
||||
from mysql.ai.utils import (
|
||||
VAR_NAME_SPACE,
|
||||
atomic_transaction,
|
||||
delete_sql_table,
|
||||
execute_sql,
|
||||
extend_sql_table,
|
||||
format_value_sql,
|
||||
get_random_name,
|
||||
is_table_empty,
|
||||
source_schema,
|
||||
table_exists,
|
||||
)
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
|
||||
BASIC_EMBEDDING_QUERY = "Hello world!"
|
||||
EMBEDDING_SOURCE = "external_source"
|
||||
|
||||
VAR_EMBEDDING = f"{VAR_NAME_SPACE}.embedding"
|
||||
VAR_CONTEXT = f"{VAR_NAME_SPACE}.context"
|
||||
VAR_CONTEXT_MAP = f"{VAR_NAME_SPACE}.context_map"
|
||||
VAR_RETRIEVAL_INFO = f"{VAR_NAME_SPACE}.retrieval_info"
|
||||
VAR_OPTIONS = f"{VAR_NAME_SPACE}.options"
|
||||
|
||||
ID_SPACE = "internal_ai_id_"
|
||||
|
||||
|
||||
class MyVectorStore(VectorStore):
|
||||
"""
|
||||
MySQL-backed vector store for handling embeddings and semantic document retrieval.
|
||||
|
||||
Supports adding, deleting, and searching high-dimensional vector representations
|
||||
of documents using efficient storage and HeatWave ML similarity search procedures.
|
||||
|
||||
Supports use as a context manager: when used in a `with` statement, all backing
|
||||
tables/data are deleted automatically when the block exits (even on exception).
|
||||
|
||||
Attributes:
|
||||
db_connection (MySQLConnectionAbstract): Active MySQL database connection.
|
||||
embedder (Embeddings): Embeddings generator for computing vector representations.
|
||||
schema_name (str): SQL schema for table storage.
|
||||
table_name (Optional[str]): Name of the active table backing the store
|
||||
(or None until created).
|
||||
embedding_dimension (int): Size of embedding vectors stored.
|
||||
next_id (int): Internal counter for unique document ID generation.
|
||||
"""
|
||||
|
||||
_db_connection: MySQLConnectionAbstract = PrivateAttr()
|
||||
_embedder: Embeddings = PrivateAttr()
|
||||
_schema_name: str = PrivateAttr()
|
||||
_table_name: Optional[str] = PrivateAttr()
|
||||
_embedding_dimension: int = PrivateAttr()
|
||||
_next_id: int = PrivateAttr()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_connection: MySQLConnectionAbstract,
|
||||
embedder: Optional[Embeddings] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize a MyVectorStore with a database connection and embedding generator.
|
||||
|
||||
Args:
|
||||
db_connection: MySQL database connection for all vector operations.
|
||||
embedder: Embeddings generator used for creating and querying embeddings.
|
||||
|
||||
Raises:
|
||||
ValueError: If the schema name is not valid
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
super().__init__()
|
||||
self._next_id = 0
|
||||
|
||||
self._schema_name = source_schema(db_connection)
|
||||
self._embedder = embedder or MyEmbeddings(db_connection)
|
||||
self._db_connection = db_connection
|
||||
self._table_name: Optional[str] = None
|
||||
|
||||
# Embedding dimension determined using an example call.
|
||||
# Assumes embeddings have fixed length.
|
||||
self._embedding_dimension = len(
|
||||
self._embedder.embed_query(BASIC_EMBEDDING_QUERY)
|
||||
)
|
||||
|
||||
def _get_ids(self, num_ids: int) -> list[str]:
|
||||
"""
|
||||
Generate a batch of unique internal document IDs for vector storage.
|
||||
|
||||
Args:
|
||||
num_ids: Number of IDs to create.
|
||||
|
||||
Returns:
|
||||
List of sequentially numbered internal string IDs.
|
||||
"""
|
||||
ids = [
|
||||
f"internal_ai_id_{i}" for i in range(self._next_id, self._next_id + num_ids)
|
||||
]
|
||||
self._next_id += num_ids
|
||||
return ids
|
||||
|
||||
def _make_vector_store(self) -> None:
|
||||
"""
|
||||
Create a backing SQL table for storing vectors if not already created.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Notes:
|
||||
The table name is randomized to avoid collisions.
|
||||
Schema includes content, metadata, and embedding vector.
|
||||
"""
|
||||
if self._table_name is None:
|
||||
with atomic_transaction(self._db_connection) as cursor:
|
||||
table_name = get_random_name(
|
||||
lambda table_name: not table_exists(
|
||||
cursor, self._schema_name, table_name
|
||||
)
|
||||
)
|
||||
|
||||
create_table_stmt = f"""
|
||||
CREATE TABLE {self._schema_name}.{table_name} (
|
||||
`id` VARCHAR(128) NOT NULL,
|
||||
`content` TEXT,
|
||||
`metadata` JSON DEFAULT NULL,
|
||||
`embed` vector(%s),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB;
|
||||
"""
|
||||
execute_sql(
|
||||
cursor, create_table_stmt, params=(self._embedding_dimension,)
|
||||
)
|
||||
|
||||
self._table_name = table_name
|
||||
|
||||
def delete(self, ids: Optional[Sequence[str]] = None, **_: Any) -> None:
|
||||
"""
|
||||
Delete documents by ID. Optionally deletes the vector table if empty after deletions.
|
||||
|
||||
Args:
|
||||
ids: Optional sequence of document IDs to delete. If None, no action is taken.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Notes:
|
||||
If the backing table is empty after deletions, the table is dropped and
|
||||
table_name is set to None.
|
||||
"""
|
||||
with atomic_transaction(self._db_connection) as cursor:
|
||||
if ids:
|
||||
for _id in ids:
|
||||
execute_sql(
|
||||
cursor,
|
||||
f"DELETE FROM {self._schema_name}.{self._table_name} WHERE id = %s",
|
||||
params=(_id,),
|
||||
)
|
||||
|
||||
if is_table_empty(cursor, self._schema_name, self._table_name):
|
||||
self.delete_all()
|
||||
|
||||
def delete_all(self) -> None:
|
||||
"""
|
||||
Delete and drop the entire vector store table.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if self._table_name is not None:
|
||||
with atomic_transaction(self._db_connection) as cursor:
|
||||
delete_sql_table(cursor, self._schema_name, self._table_name)
|
||||
self._table_name = None
|
||||
|
||||
def add_texts(
|
||||
self,
|
||||
texts: Iterable[str],
|
||||
metadatas: Optional[list[dict]] = None,
|
||||
ids: Optional[List[str]] = None,
|
||||
**_: dict,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Add a batch of text strings and corresponding metadata to the vector store.
|
||||
|
||||
Args:
|
||||
texts: List of strings to embed and store.
|
||||
metadatas: Optional list of metadata dicts (one per text).
|
||||
ids: Optional custom document IDs.
|
||||
|
||||
Returns:
|
||||
List of document IDs corresponding to the added texts.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Notes:
|
||||
If metadatas is None, an empty dict is assigned to each document.
|
||||
"""
|
||||
texts = list(texts)
|
||||
|
||||
documents = [
|
||||
Document(page_content=text, metadata=meta)
|
||||
for text, meta in zip(texts, metadatas or [{}] * len(texts))
|
||||
]
|
||||
return self.add_documents(documents, ids=ids)
|
||||
|
||||
@classmethod
|
||||
def from_texts(
|
||||
cls,
|
||||
texts: Iterable[str],
|
||||
embedder: Embeddings,
|
||||
metadatas: Optional[list[dict]] = None,
|
||||
db_connection: MySQLConnectionAbstract = None,
|
||||
) -> VectorStore:
|
||||
"""
|
||||
Construct and populate a MyVectorStore instance from raw texts and metadata.
|
||||
|
||||
Args:
|
||||
texts: List of strings to vectorize and store.
|
||||
embedder: Embeddings generator to use.
|
||||
metadatas: Optional list of metadata dicts per text.
|
||||
db_connection: Active MySQL connection.
|
||||
|
||||
Returns:
|
||||
Instance of MyVectorStore containing the added texts.
|
||||
|
||||
Raises:
|
||||
ValueError: If db_connection is not provided.
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
if db_connection is None:
|
||||
raise ValueError(
|
||||
"db_connection must be specified to create a MyVectorStore object"
|
||||
)
|
||||
|
||||
texts = list(texts)
|
||||
|
||||
instance = cls(db_connection=db_connection, embedder=embedder)
|
||||
instance.add_texts(texts, metadatas=metadatas)
|
||||
|
||||
return instance
|
||||
|
||||
def add_documents(
|
||||
self, documents: list[Document], ids: list[str] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Embed and store Document objects as high-dimensional vectors with metadata.
|
||||
|
||||
Args:
|
||||
documents: List of Document objects (each with 'page_content' and 'metadata').
|
||||
ids: Optional list of explicit document IDs. Must match the length of documents.
|
||||
|
||||
Returns:
|
||||
List of document IDs stored.
|
||||
|
||||
Raises:
|
||||
ValueError: If provided IDs do not match the number of documents.
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Notes:
|
||||
Automatically creates the backing table if it does not exist.
|
||||
"""
|
||||
if ids and len(ids) != len(documents):
|
||||
msg = (
|
||||
"ids must be the same length as documents. "
|
||||
f"Got {len(ids)} ids and {len(documents)} documents."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if len(documents) > 0:
|
||||
self._make_vector_store()
|
||||
else:
|
||||
return []
|
||||
|
||||
if ids is None:
|
||||
ids = self._get_ids(len(documents))
|
||||
|
||||
content = [doc.page_content for doc in documents]
|
||||
vectors = self._embedder.embed_documents(content)
|
||||
|
||||
df = pd.DataFrame()
|
||||
df["id"] = ids
|
||||
df["content"] = content
|
||||
df["embed"] = vectors
|
||||
df["metadata"] = [doc.metadata for doc in documents]
|
||||
|
||||
with atomic_transaction(self._db_connection) as cursor:
|
||||
extend_sql_table(
|
||||
cursor,
|
||||
self._schema_name,
|
||||
self._table_name,
|
||||
df,
|
||||
col_name_to_placeholder_string={"embed": "string_to_vector(%s)"},
|
||||
)
|
||||
|
||||
return ids
|
||||
|
||||
def similarity_search(
|
||||
self,
|
||||
query: str,
|
||||
k: int = 3,
|
||||
**kwargs: Any,
|
||||
) -> list[Document]:
|
||||
"""
|
||||
Search for and return the most similar documents in the store to the given query.
|
||||
|
||||
Args:
|
||||
query: String query to embed and use for similarity search.
|
||||
k: Number of top documents to return.
|
||||
kwargs: options to pass to ML_SIMILARITY_SEARCH. Currently supports
|
||||
distance_metric, max_distance, percentage_distance, and segment_overlap
|
||||
|
||||
Returns:
|
||||
List of Document objects, ordered from most to least similar.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided kwargs are invalid or unsupported.
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Implementation Notes:
|
||||
- Calls ML similarity search within MySQL using stored procedures.
|
||||
- Retrieves IDs, content, and metadata for search matches.
|
||||
- Parsing and retrieval for context results are handled via intermediate JSONs.
|
||||
"""
|
||||
if self._table_name is None:
|
||||
return []
|
||||
|
||||
embedding = self._embedder.embed_query(query)
|
||||
|
||||
with atomic_transaction(self._db_connection) as cursor:
|
||||
# Set the embedding variable for the similarity search SP
|
||||
execute_sql(
|
||||
cursor,
|
||||
f"SET @{VAR_EMBEDDING} = string_to_vector(%s)",
|
||||
params=[str(embedding)],
|
||||
)
|
||||
|
||||
distance_metric = kwargs.get("distance_metric", "COSINE")
|
||||
retrieval_options = {
|
||||
"max_distance": kwargs.get("max_distance", 0.6),
|
||||
"percentage_distance": kwargs.get("percentage_distance", 20.0),
|
||||
"segment_overlap": kwargs.get("segment_overlap", 0),
|
||||
}
|
||||
|
||||
retrieval_options_placeholder, retrieval_options_params = format_value_sql(
|
||||
retrieval_options
|
||||
)
|
||||
similarity_search_query = f"""
|
||||
CALL sys.ML_SIMILARITY_SEARCH(
|
||||
@{VAR_EMBEDDING},
|
||||
JSON_ARRAY(
|
||||
'{self._schema_name}.{self._table_name}'
|
||||
),
|
||||
JSON_OBJECT(
|
||||
"segment", "content",
|
||||
"segment_embedding", "embed",
|
||||
"document_name", "id"
|
||||
),
|
||||
{k},
|
||||
%s,
|
||||
NULL,
|
||||
NULL,
|
||||
{retrieval_options_placeholder},
|
||||
@{VAR_CONTEXT},
|
||||
@{VAR_CONTEXT_MAP},
|
||||
@{VAR_RETRIEVAL_INFO}
|
||||
)
|
||||
"""
|
||||
|
||||
execute_sql(
|
||||
cursor,
|
||||
similarity_search_query,
|
||||
params=[distance_metric, *retrieval_options_params],
|
||||
)
|
||||
execute_sql(cursor, f"SELECT @{VAR_CONTEXT_MAP}")
|
||||
|
||||
results = []
|
||||
|
||||
context_maps = json.loads(cursor.fetchone()[0])
|
||||
for context in context_maps:
|
||||
execute_sql(
|
||||
cursor,
|
||||
(
|
||||
"SELECT id, content, metadata "
|
||||
f"FROM {self._schema_name}.{self._table_name} "
|
||||
"WHERE id = %s"
|
||||
),
|
||||
params=(context["document_name"],),
|
||||
)
|
||||
doc_id, content, metadata = cursor.fetchone()
|
||||
|
||||
doc_args = {
|
||||
"id": doc_id,
|
||||
"page_content": content,
|
||||
}
|
||||
if metadata is not None:
|
||||
doc_args["metadata"] = json.loads(metadata)
|
||||
|
||||
doc = Document(**doc_args)
|
||||
results.append(doc)
|
||||
|
||||
return results
|
||||
|
||||
def __enter__(self) -> "VectorStore":
|
||||
"""
|
||||
Enter the runtime context related to this vector store instance.
|
||||
|
||||
Returns:
|
||||
The current MyVectorStore object, allowing use within a `with` statement block.
|
||||
|
||||
Usage Notes:
|
||||
- Intended for use in a `with` statement to ensure automatic
|
||||
cleanup of resources.
|
||||
- No special initialization occurs during context entry, but enables
|
||||
proper context-managed lifecycle.
|
||||
|
||||
Example:
|
||||
with MyVectorStore(db_connection, embedder) as vectorstore:
|
||||
vectorstore.add_texts([...])
|
||||
# Vector store is active within this block.
|
||||
# All storage and resources are now cleaned up.
|
||||
"""
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Union[type, None],
|
||||
exc_val: Union[BaseException, None],
|
||||
exc_tb: Union[object, None],
|
||||
) -> None:
|
||||
"""
|
||||
Exit the runtime context for the vector store, ensuring all storage
|
||||
resources are cleaned up.
|
||||
|
||||
Args:
|
||||
exc_type: The exception type, if any exception occurred in the context block.
|
||||
exc_val: The exception value, if any exception occurred in the context block.
|
||||
exc_tb: The traceback object, if any exception occurred in the context block.
|
||||
|
||||
Returns:
|
||||
None: Indicates that exceptions are never suppressed; they will propagate as normal.
|
||||
|
||||
Implementation Notes:
|
||||
- Automatically deletes all vector store data and backing tables via `delete_all()`
|
||||
upon exiting the context.
|
||||
- This cleanup occurs whether the block exits normally or due to an exception.
|
||||
- Does not suppress exceptions; errors in the context block will continue to propagate.
|
||||
- Use when the vector store lifecycle is intended to be temporary or scoped.
|
||||
|
||||
Example:
|
||||
with MyVectorStore(db_connection, embedder) as vectorstore:
|
||||
vectorstore.add_texts([...])
|
||||
# Vector store is active within this block.
|
||||
# All storage and resources are now cleaned up.
|
||||
"""
|
||||
self.delete_all()
|
||||
# No return, so exceptions are never suppressed
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""ML package for MySQL Connector/Python.
|
||||
|
||||
Performs optional dependency checks and exposes ML utilities:
|
||||
- ML_TASK, MyModel
|
||||
- MyClassifier, MyRegressor, MyGenericTransformer
|
||||
- MyAnomalyDetector
|
||||
"""
|
||||
from mysql.ai.utils import check_dependencies as _check_dependencies
|
||||
|
||||
_check_dependencies(["ML"])
|
||||
del _check_dependencies
|
||||
|
||||
# Sklearn models
|
||||
from .classifier import MyClassifier
|
||||
|
||||
# Minimal interface
|
||||
from .model import ML_TASK, MyModel
|
||||
from .outlier import MyAnomalyDetector
|
||||
from .regressor import MyRegressor
|
||||
from .transformer import MyGenericTransformer
|
||||
@@ -0,0 +1,142 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Base classes for MySQL HeatWave ML estimators for Connector/Python.
|
||||
|
||||
Implements a scikit-learn-compatible base estimator wrapping server-side ML.
|
||||
"""
|
||||
from typing import Optional, Union
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from sklearn.base import BaseEstimator
|
||||
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
from mysql.ai.ml.model import ML_TASK, MyModel
|
||||
from mysql.ai.utils import copy_dict
|
||||
|
||||
|
||||
class MyBaseMLModel(BaseEstimator):
|
||||
"""
|
||||
Base class for MySQL HeatWave machine learning estimators.
|
||||
|
||||
Implements the scikit-learn API and core model management logic,
|
||||
including fit, explain, serialization, and dynamic option handling.
|
||||
For use as a base class by classifiers, regressors, transformers, and outlier models.
|
||||
|
||||
Args:
|
||||
db_connection (MySQLConnectionAbstract): An active MySQL connector database connection.
|
||||
task (str): ML task type, e.g. "classification" or "regression".
|
||||
model_name (str, optional): Custom name for the deployed model.
|
||||
fit_extra_options (dict, optional): Extra options for fitting.
|
||||
|
||||
Attributes:
|
||||
_model: Underlying database helper for fit/predict/explain.
|
||||
fit_extra_options: User-provided options for fitting.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_connection: MySQLConnectionAbstract,
|
||||
task: Union[str, ML_TASK],
|
||||
model_name: Optional[str] = None,
|
||||
fit_extra_options: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Initialize a MyBaseMLModel with connection, task, and option parameters.
|
||||
|
||||
Args:
|
||||
db_connection: Active MySQL connector database connection.
|
||||
task: String label of ML task (e.g. "classification").
|
||||
model_name: Optional custom model name.
|
||||
fit_extra_options: Optional extra fit options.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
self._model = MyModel(db_connection, task=task, model_name=model_name)
|
||||
self.fit_extra_options = copy_dict(fit_extra_options)
|
||||
|
||||
def fit(
|
||||
self,
|
||||
X: pd.DataFrame, # pylint: disable=invalid-name
|
||||
y: Optional[pd.DataFrame] = None,
|
||||
) -> "MyBaseMLModel":
|
||||
"""
|
||||
Fit the underlying ML model using pandas DataFrames.
|
||||
Delegates to MyMLModelPandasHelper.fit.
|
||||
|
||||
Args:
|
||||
X: Features DataFrame.
|
||||
y: (Optional) Target labels DataFrame or Series.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported.
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Notes:
|
||||
Additional temp SQL resources may be created and cleaned up during the operation.
|
||||
"""
|
||||
self._model.fit(X, y, self.fit_extra_options)
|
||||
return self
|
||||
|
||||
def _delete_model(self) -> bool:
|
||||
"""
|
||||
Deletes the model from the model catalog if present
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Returns:
|
||||
Whether the model was deleted
|
||||
"""
|
||||
return self._model._delete_model()
|
||||
|
||||
def get_model_info(self) -> Optional[dict]:
|
||||
"""
|
||||
Checks if the model name is available. Model info will only be present in the
|
||||
catalog if the model has previously been fitted.
|
||||
|
||||
Returns:
|
||||
True if the model name is not part of the model catalog
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
return self._model.get_model_info()
|
||||
@@ -0,0 +1,194 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Classifier utilities for MySQL Connector/Python.
|
||||
|
||||
Provides a scikit-learn compatible classifier backed by HeatWave ML.
|
||||
"""
|
||||
from typing import Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.base import ClassifierMixin
|
||||
|
||||
from mysql.ai.ml.base import MyBaseMLModel
|
||||
from mysql.ai.ml.model import ML_TASK
|
||||
from mysql.ai.utils import copy_dict
|
||||
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
class MyClassifier(MyBaseMLModel, ClassifierMixin):
|
||||
"""
|
||||
MySQL HeatWave scikit-learn compatible classifier estimator.
|
||||
|
||||
Provides prediction and probability output from a model deployed in MySQL,
|
||||
and manages fit, explain, and prediction options as per HeatWave ML interface.
|
||||
|
||||
Attributes:
|
||||
predict_extra_options (dict): Dictionary of optional parameters passed through
|
||||
to the MySQL backend for prediction and probability inference.
|
||||
_model (MyModel): Underlying interface for database model operations.
|
||||
fit_extra_options (dict): See MyBaseMLModel.
|
||||
|
||||
Args:
|
||||
db_connection (MySQLConnectionAbstract): Active MySQL connector DB connection.
|
||||
model_name (str, optional): Custom name for the model.
|
||||
fit_extra_options (dict, optional): Extra options for fitting.
|
||||
explain_extra_options (dict, optional): Extra options for explanations.
|
||||
predict_extra_options (dict, optional): Extra options for predict/predict_proba.
|
||||
|
||||
Methods:
|
||||
predict(X): Predict class labels.
|
||||
predict_proba(X): Predict class probabilities.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_connection: MySQLConnectionAbstract,
|
||||
model_name: Optional[str] = None,
|
||||
fit_extra_options: Optional[dict] = None,
|
||||
explain_extra_options: Optional[dict] = None,
|
||||
predict_extra_options: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Initialize a MyClassifier.
|
||||
|
||||
Args:
|
||||
db_connection: Active MySQL connector database connection.
|
||||
model_name: Optional, custom model name.
|
||||
fit_extra_options: Optional fit options.
|
||||
explain_extra_options: Optional explain options.
|
||||
predict_extra_options: Optional predict/predict_proba options.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
MyBaseMLModel.__init__(
|
||||
self,
|
||||
db_connection,
|
||||
ML_TASK.CLASSIFICATION,
|
||||
model_name=model_name,
|
||||
fit_extra_options=fit_extra_options,
|
||||
)
|
||||
self.predict_extra_options = copy_dict(predict_extra_options)
|
||||
self.explain_extra_options = copy_dict(explain_extra_options)
|
||||
|
||||
def predict(
|
||||
self, X: Union[pd.DataFrame, np.ndarray]
|
||||
) -> np.ndarray: # pylint: disable=invalid-name
|
||||
"""
|
||||
Predict class labels for the input features using the MySQL model.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-predict-table.html
|
||||
A full list of supported options can be found under "ML_PREDICT_TABLE Options"
|
||||
|
||||
Args:
|
||||
X: Input samples as a numpy array or pandas DataFrame.
|
||||
|
||||
Returns:
|
||||
ndarray: Array of predicted class labels, shape (n_samples,).
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
result = self._model.predict(X, options=self.predict_extra_options)
|
||||
return result["Prediction"].to_numpy()
|
||||
|
||||
def predict_proba(
|
||||
self, X: Union[pd.DataFrame, np.ndarray]
|
||||
) -> np.ndarray: # pylint: disable=invalid-name
|
||||
"""
|
||||
Predict class probabilities for the input features using the MySQL model.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-predict-table.html
|
||||
A full list of supported options can be found under "ML_PREDICT_TABLE Options"
|
||||
|
||||
Args:
|
||||
X: Input samples as a numpy array or pandas DataFrame.
|
||||
|
||||
Returns:
|
||||
ndarray: Array of shape (n_samples, n_classes) with class probabilities.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
result = self._model.predict(X, options=self.predict_extra_options)
|
||||
|
||||
classes = sorted(result["ml_results"].iloc[0]["probabilities"].keys())
|
||||
|
||||
return np.stack(
|
||||
result["ml_results"].map(
|
||||
lambda ml_result: [
|
||||
ml_result["probabilities"][class_name] for class_name in classes
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def explain_predictions(
|
||||
self, X: Union[pd.DataFrame, np.ndarray]
|
||||
) -> pd.DataFrame: # pylint: disable=invalid-name
|
||||
"""
|
||||
Explain model predictions using provided data.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-explain-table.html
|
||||
A full list of supported options can be found under "ML_EXPLAIN_TABLE Options"
|
||||
|
||||
Args:
|
||||
X: DataFrame for which predictions should be explained.
|
||||
|
||||
Returns:
|
||||
DataFrame containing explanation details (feature attributions, etc.)
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Notes:
|
||||
Temporary input/output tables are cleaned up after explanation.
|
||||
"""
|
||||
self._model.explain_predictions(X, options=self.explain_extra_options)
|
||||
@@ -0,0 +1,780 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
"""HeatWave ML model utilities for MySQL Connector/Python.
|
||||
|
||||
Provides classes to manage training, prediction, scoring, and explanations
|
||||
via MySQL HeatWave stored procedures.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from mysql.ai.utils import (
|
||||
VAR_NAME_SPACE,
|
||||
atomic_transaction,
|
||||
convert_to_df,
|
||||
execute_sql,
|
||||
format_value_sql,
|
||||
get_random_name,
|
||||
source_schema,
|
||||
sql_response_to_df,
|
||||
sql_table_from_df,
|
||||
sql_table_to_df,
|
||||
table_exists,
|
||||
temporary_sql_tables,
|
||||
validate_name,
|
||||
)
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
class ML_TASK(Enum): # pylint: disable=invalid-name
|
||||
"""Enumeration of supported ML tasks for HeatWave."""
|
||||
|
||||
CLASSIFICATION = "classification"
|
||||
REGRESSION = "regression"
|
||||
FORECASTING = "forecasting"
|
||||
ANOMALY_DETECTION = "anomaly_detection"
|
||||
LOG_ANOMALY_DETECTION = "log_anomaly_detection"
|
||||
RECOMMENDATION = "recommendation"
|
||||
TOPIC_MODELING = "topic_modeling"
|
||||
|
||||
@staticmethod
|
||||
def get_task_string(task: Union[str, "ML_TASK"]) -> str:
|
||||
"""
|
||||
Return the string representation of a machine learning task.
|
||||
|
||||
Args:
|
||||
task (Union[str, ML_TASK]): The task to convert.
|
||||
Accepts either a task enum member (ML_TASK) or a string.
|
||||
|
||||
Returns:
|
||||
str: The string value of the ML task.
|
||||
"""
|
||||
|
||||
if isinstance(task, str):
|
||||
return task
|
||||
|
||||
return task.value
|
||||
|
||||
|
||||
class _MyModelCommon:
|
||||
"""
|
||||
Common utilities and workflow for MySQL HeatWave ML models.
|
||||
|
||||
This class handles model lifecycle steps such as loading, fitting, scoring,
|
||||
making predictions, and explaining models or predictions. Not intended for
|
||||
direct instantiation, but as a superclass for heatwave model wrappers.
|
||||
|
||||
Attributes:
|
||||
db_connection: MySQL connector database connection.
|
||||
task: ML task, e.g., "classification" or "regression".
|
||||
model_name: Identifier of model in MySQL.
|
||||
schema_name: Database schema used for operations and temp tables.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_connection: MySQLConnectionAbstract,
|
||||
task: Union[str, ML_TASK] = ML_TASK.CLASSIFICATION,
|
||||
model_name: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Instantiate _MyMLModelCommon.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-train.html
|
||||
A full list of supported tasks can be found under "Common ML_TRAIN Options"
|
||||
|
||||
Args:
|
||||
db_connection: MySQL database connection.
|
||||
task: ML task type (default: "classification").
|
||||
model_name: Name to register the model within MySQL (default: None).
|
||||
|
||||
Raises:
|
||||
ValueError: If the schema name is not valid
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self.db_connection = db_connection
|
||||
self.task = ML_TASK.get_task_string(task)
|
||||
self.schema_name = source_schema(db_connection)
|
||||
|
||||
with atomic_transaction(self.db_connection) as cursor:
|
||||
execute_sql(cursor, "CALL sys.ML_CREATE_OR_UPGRADE_CATALOG();")
|
||||
|
||||
if model_name is None:
|
||||
model_name = get_random_name(self._is_model_name_available)
|
||||
|
||||
self.model_var = f"{VAR_NAME_SPACE}.{model_name}"
|
||||
self.model_var_score = f"{self.model_var}.score"
|
||||
|
||||
self.model_name = model_name
|
||||
validate_name(model_name)
|
||||
|
||||
with atomic_transaction(self.db_connection) as cursor:
|
||||
execute_sql(cursor, f"SET @{self.model_var} = %s;", params=(model_name,))
|
||||
|
||||
def _delete_model(self) -> bool:
|
||||
"""
|
||||
Deletes the model from the model catalog if present
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Returns:
|
||||
Whether the model was deleted
|
||||
"""
|
||||
current_user = self._get_user()
|
||||
|
||||
qualified_model_catalog = f"ML_SCHEMA_{current_user}.MODEL_CATALOG"
|
||||
delete_model = (
|
||||
f"DELETE FROM {qualified_model_catalog} "
|
||||
f"WHERE model_handle = @{self.model_var}"
|
||||
)
|
||||
|
||||
with atomic_transaction(self.db_connection) as cursor:
|
||||
execute_sql(cursor, delete_model)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def _get_model_info(self, model_name: str) -> Optional[dict]:
|
||||
"""
|
||||
Retrieves the model info from the model_catalog
|
||||
|
||||
Args:
|
||||
model_var: The model alias to retrieve
|
||||
|
||||
Returns:
|
||||
The model info from the model_catalog (None if the model is not present in the catalog)
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
|
||||
def process_col(elem: Any) -> Any:
|
||||
if isinstance(elem, str):
|
||||
try:
|
||||
elem = json.loads(elem)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return elem
|
||||
|
||||
current_user = self._get_user()
|
||||
|
||||
qualified_model_catalog = f"ML_SCHEMA_{current_user}.MODEL_CATALOG"
|
||||
model_exists = (
|
||||
f"SELECT * FROM {qualified_model_catalog} WHERE model_handle = %s"
|
||||
)
|
||||
|
||||
with atomic_transaction(self.db_connection) as cursor:
|
||||
execute_sql(cursor, model_exists, params=(model_name,))
|
||||
model_info_df = sql_response_to_df(cursor)
|
||||
|
||||
if model_info_df.empty:
|
||||
result = None
|
||||
else:
|
||||
unprocessed_result = model_info_df.to_json(orient="records")
|
||||
unprocessed_result_json = json.loads(unprocessed_result)[0]
|
||||
result = {
|
||||
key: process_col(elem)
|
||||
for key, elem in unprocessed_result_json.items()
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def get_model_info(self) -> Optional[dict]:
|
||||
"""
|
||||
Checks if the model name is available.
|
||||
Model info is present in the catalog only if the model was previously fitted.
|
||||
|
||||
Returns:
|
||||
True if the model name is not part of the model catalog
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
return self._get_model_info(self.model_name)
|
||||
|
||||
def _is_model_name_available(self, model_name: str) -> bool:
|
||||
"""
|
||||
Checks if the model name is available
|
||||
|
||||
Returns:
|
||||
True if the model name is not part of the model catalog
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
return self._get_model_info(model_name) is None
|
||||
|
||||
def _load_model(self) -> None:
|
||||
"""
|
||||
Loads the model specified by `self.model_name` into MySQL.
|
||||
After loading, the model is ready to handle ML operations.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-model-load.html
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If the model is not initialized, i.e., fit or import has not been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
with atomic_transaction(self.db_connection) as cursor:
|
||||
load_model_query = f"CALL sys.ML_MODEL_LOAD(@{self.model_var}, NULL);"
|
||||
execute_sql(cursor, load_model_query)
|
||||
|
||||
def _get_user(self) -> str:
|
||||
"""
|
||||
Fetch the current database user (without host).
|
||||
|
||||
Returns:
|
||||
The username string associated with the connection.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError: If the user name includes unsupported characters
|
||||
"""
|
||||
with atomic_transaction(self.db_connection) as cursor:
|
||||
cursor.execute("SELECT CURRENT_USER()")
|
||||
current_user = cursor.fetchone()[0].split("@")[0]
|
||||
|
||||
return validate_name(current_user)
|
||||
|
||||
def explain_model(self) -> dict:
|
||||
"""
|
||||
Get model explanations, such as detailed feature importances.
|
||||
|
||||
Returns:
|
||||
dict: Feature importances and model explainability data.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-model-explanations.html
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If the model is not initialized, i.e., fit or import has not been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError:
|
||||
If the model does not exist in the model catalog.
|
||||
Should only occur if model was not fitted or was deleted.
|
||||
"""
|
||||
self._load_model()
|
||||
with atomic_transaction(self.db_connection) as cursor:
|
||||
current_user = self._get_user()
|
||||
|
||||
qualified_model_catalog = f"ML_SCHEMA_{current_user}.MODEL_CATALOG"
|
||||
explain_query = (
|
||||
f"SELECT model_explanation FROM {qualified_model_catalog} "
|
||||
f"WHERE model_handle = @{self.model_var}"
|
||||
)
|
||||
|
||||
execute_sql(cursor, explain_query)
|
||||
df = sql_response_to_df(cursor)
|
||||
|
||||
return df.iloc[0, 0]
|
||||
|
||||
def _fit(
|
||||
self,
|
||||
table_name: str,
|
||||
target_column_name: Optional[str],
|
||||
options: Optional[dict],
|
||||
) -> None:
|
||||
"""
|
||||
Fit an ML model using a referenced SQL table and target column.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-train.html
|
||||
A full list of supported options can be found under "Common ML_TRAIN Options"
|
||||
|
||||
Args:
|
||||
table_name: Name of the training data table.
|
||||
target_column_name: Name of the target/label column.
|
||||
options: Additional fit/config options (may override defaults).
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported.
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError: If the table or target_column name is not valid
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
validate_name(table_name)
|
||||
if target_column_name is not None:
|
||||
validate_name(target_column_name)
|
||||
target_col_string = f"'{target_column_name}'"
|
||||
else:
|
||||
target_col_string = "NULL"
|
||||
|
||||
if options is None:
|
||||
options = {}
|
||||
options = copy.deepcopy(options)
|
||||
options["task"] = self.task
|
||||
|
||||
self._delete_model()
|
||||
|
||||
with atomic_transaction(self.db_connection) as cursor:
|
||||
placeholders, parameters = format_value_sql(options)
|
||||
execute_sql(
|
||||
cursor,
|
||||
(
|
||||
"CALL sys.ML_TRAIN("
|
||||
f"'{self.schema_name}.{table_name}', "
|
||||
f"{target_col_string}, "
|
||||
f"{placeholders}, "
|
||||
f"@{self.model_var}"
|
||||
")"
|
||||
),
|
||||
params=parameters,
|
||||
)
|
||||
|
||||
def _predict(
|
||||
self, table_name: str, output_table_name: str, options: Optional[dict]
|
||||
) -> None:
|
||||
"""
|
||||
Predict on a given data table and write results to an output table.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-predict-table.html
|
||||
A full list of supported options can be found under "ML_PREDICT_TABLE Options"
|
||||
|
||||
Args:
|
||||
table_name: Name of the SQL table with input data.
|
||||
output_table_name: Name for the SQL output table to contain predictions.
|
||||
options: Optional prediction options.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError: If the table or output_table name is not valid
|
||||
"""
|
||||
validate_name(table_name)
|
||||
validate_name(output_table_name)
|
||||
|
||||
self._load_model()
|
||||
with atomic_transaction(self.db_connection) as cursor:
|
||||
placeholders, parameters = format_value_sql(options)
|
||||
execute_sql(
|
||||
cursor,
|
||||
(
|
||||
"CALL sys.ML_PREDICT_TABLE("
|
||||
f"'{self.schema_name}.{table_name}', "
|
||||
f"@{self.model_var}, "
|
||||
f"'{self.schema_name}.{output_table_name}', "
|
||||
f"{placeholders}"
|
||||
")"
|
||||
),
|
||||
params=parameters,
|
||||
)
|
||||
|
||||
def _score(
|
||||
self,
|
||||
table_name: str,
|
||||
target_column_name: str,
|
||||
metric: str,
|
||||
options: Optional[dict],
|
||||
) -> float:
|
||||
"""
|
||||
Evaluate model performance with a scoring metric.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-score.html
|
||||
A full list of supported options can be found under
|
||||
"Options for Recommendation Models" and
|
||||
"Options for Anomaly Detection Models"
|
||||
|
||||
Args:
|
||||
table_name: Table with features and ground truth.
|
||||
target_column_name: Column of true target labels.
|
||||
metric: String name of the metric to compute.
|
||||
options: Optional dictionary of further scoring options.
|
||||
|
||||
Returns:
|
||||
float: Computed score from the ML system.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError: If the table or target_column name or metric is not valid
|
||||
"""
|
||||
validate_name(table_name)
|
||||
validate_name(target_column_name)
|
||||
validate_name(metric)
|
||||
|
||||
self._load_model()
|
||||
with atomic_transaction(self.db_connection) as cursor:
|
||||
placeholders, parameters = format_value_sql(options)
|
||||
execute_sql(
|
||||
cursor,
|
||||
(
|
||||
"CALL sys.ML_SCORE("
|
||||
f"'{self.schema_name}.{table_name}', "
|
||||
f"'{target_column_name}', "
|
||||
f"@{self.model_var}, "
|
||||
"%s, "
|
||||
f"@{self.model_var_score}, "
|
||||
f"{placeholders}"
|
||||
")"
|
||||
),
|
||||
params=[metric, *parameters],
|
||||
)
|
||||
execute_sql(cursor, f"SELECT @{self.model_var_score}")
|
||||
df = sql_response_to_df(cursor)
|
||||
|
||||
return df.iloc[0, 0]
|
||||
|
||||
def _explain_predictions(
|
||||
self, table_name: str, output_table_name: str, options: Optional[dict]
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Produce explanations for model predictions on provided data.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-explain-table.html
|
||||
A full list of supported options can be found under "ML_EXPLAIN_TABLE Options"
|
||||
|
||||
Args:
|
||||
table_name: Name of the SQL table with input data.
|
||||
output_table_name: Name for the SQL table to store explanations.
|
||||
options: Optional dictionary (default:
|
||||
{"prediction_explainer": "permutation_importance"}).
|
||||
|
||||
Returns:
|
||||
DataFrame: Prediction explanations from the output SQL table.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError: If the table or output_table name is not valid
|
||||
"""
|
||||
validate_name(table_name)
|
||||
validate_name(output_table_name)
|
||||
|
||||
if options is None:
|
||||
options = {"prediction_explainer": "permutation_importance"}
|
||||
|
||||
self._load_model()
|
||||
|
||||
with atomic_transaction(self.db_connection) as cursor:
|
||||
placeholders, parameters = format_value_sql(options)
|
||||
execute_sql(
|
||||
cursor,
|
||||
(
|
||||
"CALL sys.ML_EXPLAIN_TABLE("
|
||||
f"'{self.schema_name}.{table_name}', "
|
||||
f"@{self.model_var}, "
|
||||
f"'{self.schema_name}.{output_table_name}', "
|
||||
f"{placeholders}"
|
||||
")"
|
||||
),
|
||||
params=parameters,
|
||||
)
|
||||
execute_sql(cursor, f"SELECT * FROM {self.schema_name}.{output_table_name}")
|
||||
df = sql_response_to_df(cursor)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
class MyModel(_MyModelCommon):
|
||||
"""
|
||||
Convenience class for managing the ML workflow using pandas DataFrames.
|
||||
|
||||
Methods convert in-memory DataFrames into temp SQL tables before delegating to the
|
||||
_MyMLModelCommon routines, and automatically clean up temp resources.
|
||||
"""
|
||||
|
||||
def fit(
|
||||
self,
|
||||
X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name
|
||||
y: Optional[Union[pd.DataFrame, np.ndarray]],
|
||||
options: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Fit a model using DataFrame inputs.
|
||||
|
||||
If an 'id' column is defined in either dataframe, it will be used as the primary key.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-train.html
|
||||
A full list of supported options can be found under "Common ML_TRAIN Options"
|
||||
|
||||
Args:
|
||||
X: Features DataFrame.
|
||||
y: (Optional) Target labels DataFrame or Series. If None, only X is used.
|
||||
options: Additional options to pass to training.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported.
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Notes:
|
||||
Combines X and y as necessary. Creates a temporary table in the schema for training,
|
||||
and deletes it afterward.
|
||||
"""
|
||||
X, y = convert_to_df(X), convert_to_df(y)
|
||||
|
||||
with (
|
||||
atomic_transaction(self.db_connection) as cursor,
|
||||
temporary_sql_tables(self.db_connection) as temporary_tables,
|
||||
):
|
||||
if y is not None:
|
||||
if isinstance(y, pd.DataFrame):
|
||||
# keep column name if it exists
|
||||
target_column_name = y.columns[0]
|
||||
else:
|
||||
target_column_name = get_random_name(
|
||||
lambda name: name not in X.columns
|
||||
)
|
||||
|
||||
if target_column_name in X.columns:
|
||||
raise ValueError(
|
||||
f"Target column y with name {target_column_name} already present "
|
||||
"in feature dataframe X"
|
||||
)
|
||||
|
||||
df_combined = X.copy()
|
||||
df_combined[target_column_name] = y
|
||||
final_df = df_combined
|
||||
else:
|
||||
target_column_name = None
|
||||
final_df = X
|
||||
|
||||
_, table_name = sql_table_from_df(cursor, self.schema_name, final_df)
|
||||
temporary_tables.append((self.schema_name, table_name))
|
||||
|
||||
self._fit(table_name, target_column_name, options)
|
||||
|
||||
def predict(
|
||||
self,
|
||||
X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name
|
||||
options: Optional[dict] = None,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Generate model predictions using DataFrame input.
|
||||
|
||||
If an 'id' column is defined in either dataframe, it will be used as the primary key.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-predict-table.html
|
||||
A full list of supported options can be found under "ML_PREDICT_TABLE Options"
|
||||
|
||||
Args:
|
||||
X: DataFrame containing prediction features (no labels).
|
||||
options: Additional prediction settings.
|
||||
|
||||
Returns:
|
||||
DataFrame with prediction results as returned by HeatWave.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Notes:
|
||||
Temporary SQL tables are created and deleted for input/output.
|
||||
"""
|
||||
X = convert_to_df(X)
|
||||
|
||||
with (
|
||||
atomic_transaction(self.db_connection) as cursor,
|
||||
temporary_sql_tables(self.db_connection) as temporary_tables,
|
||||
):
|
||||
_, table_name = sql_table_from_df(cursor, self.schema_name, X)
|
||||
temporary_tables.append((self.schema_name, table_name))
|
||||
|
||||
output_table_name = get_random_name(
|
||||
lambda table_name: not table_exists(
|
||||
cursor, self.schema_name, table_name
|
||||
)
|
||||
)
|
||||
temporary_tables.append((self.schema_name, output_table_name))
|
||||
|
||||
self._predict(table_name, output_table_name, options)
|
||||
predictions = sql_table_to_df(cursor, self.schema_name, output_table_name)
|
||||
|
||||
# ml_results is text but known to always follow JSON format
|
||||
predictions["ml_results"] = predictions["ml_results"].map(json.loads)
|
||||
|
||||
return predictions
|
||||
|
||||
def score(
|
||||
self,
|
||||
X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name
|
||||
y: Union[pd.DataFrame, np.ndarray],
|
||||
metric: str,
|
||||
options: Optional[dict] = None,
|
||||
) -> float:
|
||||
"""
|
||||
Score the model using X/y data and a selected metric.
|
||||
|
||||
If an 'id' column is defined in either dataframe, it will be used as the primary key.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-score.html
|
||||
A full list of supported options can be found under
|
||||
"Options for Recommendation Models" and
|
||||
"Options for Anomaly Detection Models"
|
||||
|
||||
Args:
|
||||
X: DataFrame of features.
|
||||
y: DataFrame or Series of labels.
|
||||
metric: Metric name (e.g., "balanced_accuracy").
|
||||
options: Optional ml scoring options.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Returns:
|
||||
float: Computed score.
|
||||
"""
|
||||
X, y = convert_to_df(X), convert_to_df(y)
|
||||
|
||||
with (
|
||||
atomic_transaction(self.db_connection) as cursor,
|
||||
temporary_sql_tables(self.db_connection) as temporary_tables,
|
||||
):
|
||||
target_column_name = get_random_name(lambda name: name not in X.columns)
|
||||
df_combined = X.copy()
|
||||
df_combined[target_column_name] = y
|
||||
final_df = df_combined
|
||||
|
||||
_, table_name = sql_table_from_df(cursor, self.schema_name, final_df)
|
||||
temporary_tables.append((self.schema_name, table_name))
|
||||
|
||||
score = self._score(table_name, target_column_name, metric, options)
|
||||
|
||||
return score
|
||||
|
||||
def explain_predictions(
|
||||
self,
|
||||
X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name
|
||||
options: Dict = None,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Explain model predictions using provided data.
|
||||
|
||||
If an 'id' column is defined in either dataframe, it will be used as the primary key.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-explain-table.html
|
||||
A full list of supported options can be found under
|
||||
"ML_EXPLAIN_TABLE Options"
|
||||
|
||||
Args:
|
||||
X: DataFrame for which predictions should be explained.
|
||||
options: Optional dictionary of explainability options.
|
||||
|
||||
Returns:
|
||||
DataFrame containing explanation details (feature attributions, etc.)
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported, or if the model is not initialized,
|
||||
i.e., fit or import has not been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Notes:
|
||||
Temporary input/output tables are cleaned up after explanation.
|
||||
"""
|
||||
X = convert_to_df(X)
|
||||
|
||||
with (
|
||||
atomic_transaction(self.db_connection) as cursor,
|
||||
temporary_sql_tables(self.db_connection) as temporary_tables,
|
||||
):
|
||||
|
||||
_, table_name = sql_table_from_df(cursor, self.schema_name, X)
|
||||
temporary_tables.append((self.schema_name, table_name))
|
||||
|
||||
output_table_name = get_random_name(
|
||||
lambda table_name: not table_exists(
|
||||
cursor, self.schema_name, table_name
|
||||
)
|
||||
)
|
||||
temporary_tables.append((self.schema_name, output_table_name))
|
||||
|
||||
explanations = self._explain_predictions(
|
||||
table_name, output_table_name, options
|
||||
)
|
||||
|
||||
return explanations
|
||||
@@ -0,0 +1,221 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Outlier/anomaly detection utilities for MySQL Connector/Python.
|
||||
|
||||
Provides a scikit-learn compatible wrapper using HeatWave to score anomalies.
|
||||
"""
|
||||
from typing import Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.base import OutlierMixin
|
||||
|
||||
from mysql.ai.ml.base import MyBaseMLModel
|
||||
from mysql.ai.ml.model import ML_TASK
|
||||
from mysql.ai.utils import copy_dict
|
||||
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
|
||||
EPS = 1e-5
|
||||
|
||||
|
||||
def _get_logits(prob: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
|
||||
"""
|
||||
Compute logit (logodds) for a probability, clipping to avoid numerical overflow.
|
||||
|
||||
Args:
|
||||
prob: Scalar or array of probability values in (0,1).
|
||||
|
||||
Returns:
|
||||
logit-transformed probabilities.
|
||||
"""
|
||||
result = np.clip(prob, EPS, 1 - EPS)
|
||||
return np.log(result / (1 - result))
|
||||
|
||||
|
||||
class MyAnomalyDetector(MyBaseMLModel, OutlierMixin):
|
||||
"""
|
||||
MySQL HeatWave scikit-learn compatible anomaly/outlier detector.
|
||||
|
||||
Flags samples as outliers when the probability of being an anomaly
|
||||
exceeds a user-tunable threshold.
|
||||
Includes helpers to obtain decision scores and anomaly probabilities
|
||||
for ranking.
|
||||
|
||||
Args:
|
||||
db_connection (MySQLConnectionAbstract): Active MySQL DB connection.
|
||||
model_name (str, optional): Custom model name in the database.
|
||||
fit_extra_options (dict, optional): Extra options for fitting.
|
||||
score_extra_options (dict, optional): Extra options for scoring/prediction.
|
||||
|
||||
Attributes:
|
||||
boundary: Decision threshold boundary in logit space. Derived from
|
||||
trained model's catalog info
|
||||
|
||||
Methods:
|
||||
predict(X): Predict outlier/inlier labels.
|
||||
score_samples(X): Compute anomaly (normal class) logit scores.
|
||||
decision_function(X): Compute signed score above/below threshold for ranking.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_connection: MySQLConnectionAbstract,
|
||||
model_name: Optional[str] = None,
|
||||
fit_extra_options: Optional[dict] = None,
|
||||
score_extra_options: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Initialize an anomaly detector instance with threshold and extra options.
|
||||
|
||||
Args:
|
||||
db_connection: Active MySQL DB connection.
|
||||
model_name: Optional model name in DB.
|
||||
fit_extra_options: Optional extra fit options.
|
||||
score_extra_options: Optional extra scoring options.
|
||||
|
||||
Raises:
|
||||
ValueError: If outlier_threshold is not in (0,1).
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
MyBaseMLModel.__init__(
|
||||
self,
|
||||
db_connection,
|
||||
ML_TASK.ANOMALY_DETECTION,
|
||||
model_name=model_name,
|
||||
fit_extra_options=fit_extra_options,
|
||||
)
|
||||
self.score_extra_options = copy_dict(score_extra_options)
|
||||
self.boundary: Optional[float] = None
|
||||
|
||||
def predict(
|
||||
self,
|
||||
X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Predict outlier/inlier binary labels for input samples.
|
||||
|
||||
Args:
|
||||
X: Samples to predict on.
|
||||
|
||||
Returns:
|
||||
ndarray: Values are -1 for outliers, +1 for inliers, as per scikit-learn convention.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
return np.where(self.decision_function(X) < 0.0, -1, 1)
|
||||
|
||||
def decision_function(
|
||||
self,
|
||||
X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Compute signed distance to the outlier threshold.
|
||||
|
||||
Args:
|
||||
X: Samples to predict on.
|
||||
|
||||
Returns:
|
||||
ndarray: Score > 0 means inlier, < 0 means outlier; |value| gives margin.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError:
|
||||
If the provided model info does not provide threshold
|
||||
"""
|
||||
sample_scores = self.score_samples(X)
|
||||
|
||||
if self.boundary is None:
|
||||
model_info = self.get_model_info()
|
||||
if model_info is None:
|
||||
raise ValueError("Model does not exist in catalog.")
|
||||
|
||||
threshold = model_info["model_metadata"]["training_params"].get(
|
||||
"anomaly_detection_threshold", None
|
||||
)
|
||||
if threshold is None:
|
||||
raise ValueError(
|
||||
"Trained model is outdated and does not support threshold. "
|
||||
"Try retraining or using an existing, trained model with MyModel."
|
||||
)
|
||||
|
||||
# scikit-learn uses large positive values as inlier
|
||||
# and negative as outlier, so we need to flip our threshold
|
||||
self.boundary = _get_logits(1.0 - threshold)
|
||||
|
||||
return sample_scores - self.boundary
|
||||
|
||||
def score_samples(
|
||||
self,
|
||||
X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Compute normal probability logit score for each sample.
|
||||
Used for ranking, thresholding.
|
||||
|
||||
Args:
|
||||
X: Samples to score.
|
||||
|
||||
Returns:
|
||||
ndarray: Logit scores based on "normal" class probability.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
result = self._model.predict(X, options=self.score_extra_options)
|
||||
|
||||
return _get_logits(
|
||||
result["ml_results"]
|
||||
.apply(lambda x: x["probabilities"]["normal"])
|
||||
.to_numpy()
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Regressor utilities for MySQL Connector/Python.
|
||||
|
||||
Provides a scikit-learn compatible regressor backed by HeatWave ML.
|
||||
"""
|
||||
from typing import Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.base import RegressorMixin
|
||||
|
||||
from mysql.ai.ml.base import MyBaseMLModel
|
||||
from mysql.ai.ml.model import ML_TASK
|
||||
from mysql.ai.utils import copy_dict
|
||||
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
class MyRegressor(MyBaseMLModel, RegressorMixin):
|
||||
"""
|
||||
MySQL HeatWave scikit-learn compatible regressor estimator.
|
||||
|
||||
Provides prediction output from a regression model deployed in MySQL,
|
||||
and manages fit, explain, and prediction options as per HeatWave ML interface.
|
||||
|
||||
Attributes:
|
||||
predict_extra_options (dict): Optional parameter dict passed to the backend for prediction.
|
||||
_model (MyModel): Underlying interface for database model operations.
|
||||
fit_extra_options (dict): See MyBaseMLModel.
|
||||
explain_extra_options (dict): See MyBaseMLModel.
|
||||
|
||||
Args:
|
||||
db_connection (MySQLConnectionAbstract): Active MySQL connector DB connection.
|
||||
model_name (str, optional): Custom name for the model.
|
||||
fit_extra_options (dict, optional): Extra options for fitting.
|
||||
explain_extra_options (dict, optional): Extra options for explanations.
|
||||
predict_extra_options (dict, optional): Extra options for predictions.
|
||||
|
||||
Methods:
|
||||
predict(X): Predict regression target.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_connection: MySQLConnectionAbstract,
|
||||
model_name: Optional[str] = None,
|
||||
fit_extra_options: Optional[dict] = None,
|
||||
explain_extra_options: Optional[dict] = None,
|
||||
predict_extra_options: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Initialize a MyRegressor.
|
||||
|
||||
Args:
|
||||
db_connection: Active MySQL connector database connection.
|
||||
model_name: Optional, custom model name.
|
||||
fit_extra_options: Optional fit options.
|
||||
explain_extra_options: Optional explain options.
|
||||
predict_extra_options: Optional prediction options.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
MyBaseMLModel.__init__(
|
||||
self,
|
||||
db_connection,
|
||||
ML_TASK.REGRESSION,
|
||||
model_name=model_name,
|
||||
fit_extra_options=fit_extra_options,
|
||||
)
|
||||
|
||||
self.predict_extra_options = copy_dict(predict_extra_options)
|
||||
self.explain_extra_options = copy_dict(explain_extra_options)
|
||||
|
||||
def predict(
|
||||
self, X: Union[pd.DataFrame, np.ndarray]
|
||||
) -> np.ndarray: # pylint: disable=invalid-name
|
||||
"""
|
||||
Predict a continuous target for the input features using the MySQL model.
|
||||
|
||||
Args:
|
||||
X: Input samples as a numpy array or pandas DataFrame.
|
||||
|
||||
Returns:
|
||||
ndarray: Array of predicted target values, shape (n_samples,).
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
result = self._model.predict(X, options=self.predict_extra_options)
|
||||
return result["Prediction"].to_numpy()
|
||||
|
||||
def explain_predictions(
|
||||
self, X: Union[pd.DataFrame, np.ndarray]
|
||||
) -> pd.DataFrame: # pylint: disable=invalid-name
|
||||
"""
|
||||
Explain model predictions using provided data.
|
||||
|
||||
References:
|
||||
https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-explain-table.html
|
||||
A full list of supported options can be found under "ML_EXPLAIN_TABLE Options"
|
||||
|
||||
Args:
|
||||
X: DataFrame for which predictions should be explained.
|
||||
|
||||
Returns:
|
||||
DataFrame containing explanation details (feature attributions, etc.)
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Notes:
|
||||
Temporary input/output tables are cleaned up after explanation.
|
||||
"""
|
||||
self._model.explain_predictions(X, options=self.explain_extra_options)
|
||||
@@ -0,0 +1,164 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
"""Generic transformer utilities for MySQL Connector/Python.
|
||||
|
||||
Provides a scikit-learn compatible Transformer using HeatWave for fit/transform
|
||||
and scoring operations.
|
||||
"""
|
||||
from typing import Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.base import TransformerMixin
|
||||
|
||||
from mysql.ai.ml.base import MyBaseMLModel
|
||||
from mysql.ai.ml.model import ML_TASK
|
||||
from mysql.ai.utils import copy_dict
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
class MyGenericTransformer(MyBaseMLModel, TransformerMixin):
|
||||
"""
|
||||
MySQL HeatWave scikit-learn compatible generic transformer.
|
||||
|
||||
Can be used as the transformation step in an sklearn pipeline. Implements fit, transform,
|
||||
explain, and scoring capability, passing options for server-side transform logic.
|
||||
|
||||
Args:
|
||||
db_connection (MySQLConnectionAbstract): Active MySQL connector database connection.
|
||||
task (str): ML task type for transformer (default: "classification").
|
||||
score_metric (str): Scoring metric to request from backend (default: "balanced_accuracy").
|
||||
model_name (str, optional): Custom name for the deployed model.
|
||||
fit_extra_options (dict, optional): Extra fit options.
|
||||
transform_extra_options (dict, optional): Extra options for transformations.
|
||||
score_extra_options (dict, optional): Extra options for scoring.
|
||||
|
||||
Attributes:
|
||||
score_metric (str): Name of the backend metric to use for scoring
|
||||
(e.g. "balanced_accuracy").
|
||||
score_extra_options (dict): Dictionary of optional scoring parameters;
|
||||
passed to backend score.
|
||||
transform_extra_options (dict): Dictionary of inference (/predict)
|
||||
parameters for the backend.
|
||||
fit_extra_options (dict): See MyBaseMLModel.
|
||||
_model (MyModel): Underlying interface for database model operations.
|
||||
|
||||
Methods:
|
||||
fit(X, y): Fit the underlying model using the provided features/targets.
|
||||
transform(X): Transform features using the backend model.
|
||||
score(X, y): Score data using backend metric and options.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_connection: MySQLConnectionAbstract,
|
||||
task: Union[str, ML_TASK] = ML_TASK.CLASSIFICATION,
|
||||
score_metric: str = "balanced_accuracy",
|
||||
model_name: Optional[str] = None,
|
||||
fit_extra_options: Optional[dict] = None,
|
||||
transform_extra_options: Optional[dict] = None,
|
||||
score_extra_options: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Initialize transformer with required and optional arguments.
|
||||
|
||||
Args:
|
||||
db_connection: Active MySQL backend database connection.
|
||||
task: ML task type for transformer.
|
||||
score_metric: Requested backend scoring metric.
|
||||
model_name: Optional model name for storage.
|
||||
fit_extra_options: Optional extra options for fitting.
|
||||
transform_extra_options: Optional extra options for transformation/inference.
|
||||
score_extra_options: Optional extra scoring options.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
MyBaseMLModel.__init__(
|
||||
self,
|
||||
db_connection,
|
||||
task,
|
||||
model_name=model_name,
|
||||
fit_extra_options=fit_extra_options,
|
||||
)
|
||||
|
||||
self.score_metric = score_metric
|
||||
self.score_extra_options = copy_dict(score_extra_options)
|
||||
|
||||
self.transform_extra_options = copy_dict(transform_extra_options)
|
||||
|
||||
def transform(
|
||||
self, X: pd.DataFrame
|
||||
) -> pd.DataFrame: # pylint: disable=invalid-name
|
||||
"""
|
||||
Transform input data to model predictions using the underlying helper.
|
||||
|
||||
Args:
|
||||
X: DataFrame of features to predict/transform.
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: Results of transformation as returned by backend.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
return self._model.predict(X, options=self.transform_extra_options)
|
||||
|
||||
def score(
|
||||
self,
|
||||
X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name
|
||||
y: Union[pd.DataFrame, np.ndarray],
|
||||
) -> float:
|
||||
"""
|
||||
Score the transformed data using the backend scoring interface.
|
||||
|
||||
Args:
|
||||
X: Transformed features.
|
||||
y: Target labels or data for scoring.
|
||||
|
||||
Returns:
|
||||
float: Score based on backend metric.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If provided options are invalid or unsupported,
|
||||
or if the model is not initialized, i.e., fit or import has not
|
||||
been called
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
return self._model.score(
|
||||
X, y, self.score_metric, options=self.score_extra_options
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Utilities for AI-related helpers in MySQL Connector/Python.
|
||||
|
||||
This package exposes:
|
||||
- check_dependencies(): runtime dependency guard for optional AI features
|
||||
- atomic_transaction(): context manager ensuring atomic DB transactions
|
||||
- utils: general-purpose helpers used by AI integrations
|
||||
|
||||
Importing this package validates base dependencies required for AI utilities.
|
||||
"""
|
||||
|
||||
from .dependencies import check_dependencies
|
||||
|
||||
check_dependencies(["BASE"])
|
||||
|
||||
from .atomic_cursor import atomic_transaction
|
||||
from .utils import *
|
||||
@@ -0,0 +1,94 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Atomic transaction context manager utilities for MySQL Connector/Python.
|
||||
|
||||
Provides context manager atomic_transaction() that ensures commit on success
|
||||
and rollback on error without obscuring the original exception.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
from mysql.connector.cursor import MySQLCursorAbstract
|
||||
|
||||
|
||||
@contextmanager
|
||||
def atomic_transaction(
|
||||
conn: MySQLConnectionAbstract,
|
||||
) -> Iterator[MySQLCursorAbstract]:
|
||||
"""
|
||||
Context manager that wraps a MySQL database cursor and ensures transaction
|
||||
rollback in case of exception.
|
||||
|
||||
NOTE: DDL statements such as CREATE TABLE cause implicit commits. These cannot
|
||||
be managed by a cursor object. Changes made at or before a DDL statement will
|
||||
be committed and not rolled back. Callers are responsible for any cleanup of
|
||||
this type.
|
||||
|
||||
This class acts as a robust, PEP 343-compliant context manager for handling
|
||||
database cursor operations on a MySQL connection. It ensures that all operations
|
||||
executed within the context block are part of the same transaction, and
|
||||
automatically calls `connection.rollback()` if an exception occurs, helping
|
||||
to maintain database integrity. On normal completion (no exception), it simply
|
||||
closes the cursor after use. Exceptions are always propagated to the caller.
|
||||
|
||||
Args:
|
||||
conn: A MySQLConnectionAbstract instance.
|
||||
"""
|
||||
old_autocommit = conn.autocommit
|
||||
cursor = conn.cursor()
|
||||
|
||||
exception_raised = False
|
||||
try:
|
||||
if old_autocommit:
|
||||
conn.autocommit = False
|
||||
|
||||
yield cursor # provide cursor to block
|
||||
|
||||
conn.commit()
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
exception_raised = True
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
# Don't obscure original exception
|
||||
pass
|
||||
|
||||
# Raise original exception
|
||||
raise
|
||||
finally:
|
||||
conn.autocommit = old_autocommit
|
||||
|
||||
try:
|
||||
cursor.close()
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
# don't obscure original exception if exists
|
||||
if not exception_raised:
|
||||
raise
|
||||
@@ -0,0 +1,88 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Dependency checking utilities for AI features in MySQL Connector/Python.
|
||||
|
||||
Provides check_dependencies() to assert required optional packages are present
|
||||
with acceptable minimum versions at runtime.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from typing import List
|
||||
|
||||
|
||||
def check_dependencies(tasks: List[str]) -> None:
|
||||
"""
|
||||
Check required runtime dependencies and minimum versions; raise an error
|
||||
if any are missing or version-incompatible.
|
||||
|
||||
This verifies the presence and minimum version of essential Python packages.
|
||||
Missing or insufficient versions cause an ImportError listing the packages
|
||||
and a suggested install command.
|
||||
|
||||
Args:
|
||||
tasks (List[str]): Task types to check requirements for.
|
||||
|
||||
Raises:
|
||||
ImportError: If any required dependencies are missing or below the
|
||||
minimum version.
|
||||
"""
|
||||
task_set = set(tasks)
|
||||
task_set.add("BASE")
|
||||
|
||||
# Requirements: (import_name, min_version)
|
||||
task_to_requirement = {
|
||||
"BASE": [("pandas", "1.5.0")],
|
||||
"GENAI": [
|
||||
("langchain", "0.1.11"),
|
||||
("langchain_core", "0.1.11"),
|
||||
("pydantic", "1.10.0"),
|
||||
],
|
||||
"ML": [("scikit-learn", "1.3.0")],
|
||||
}
|
||||
requirements = []
|
||||
for task in task_set:
|
||||
requirements.extend(task_to_requirement[task])
|
||||
requirements_set = set(requirements)
|
||||
|
||||
problems = []
|
||||
for name, min_version in requirements_set:
|
||||
try:
|
||||
installed_version = importlib.metadata.version(name)
|
||||
# Version comparison uses simple string comparison to avoid extra
|
||||
# dependencies. This is valid for the dependencies defined above;
|
||||
# reconsider if adding packages with version schemes that do not
|
||||
# compare correctly as strings.
|
||||
error = installed_version < min_version
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
error = True
|
||||
if error:
|
||||
problems.append(f"{name} v{min_version} (or later)")
|
||||
if problems:
|
||||
raise ImportError("Please install " + ", ".join(problems) + ".")
|
||||
@@ -0,0 +1,573 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
"""General utilities for AI features in MySQL Connector/Python.
|
||||
|
||||
Includes helpers for:
|
||||
- defensive dict copying
|
||||
- temporary table lifecycle management
|
||||
- SQL execution and result conversions
|
||||
- DataFrame to/from SQL table utilities
|
||||
- schema/table/column name validation
|
||||
- array-like to DataFrame conversion
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from mysql.ai.utils.atomic_cursor import atomic_transaction
|
||||
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
from mysql.connector.cursor import MySQLCursorAbstract
|
||||
from mysql.connector.types import ParamsSequenceOrDictType
|
||||
|
||||
VAR_NAME_SPACE = "mysql_ai"
|
||||
RANDOM_TABLE_NAME_LENGTH = 32
|
||||
|
||||
PD_TO_SQL_DTYPE_MAPPING = {
|
||||
"int64": "BIGINT",
|
||||
"float64": "DOUBLE",
|
||||
"object": "LONGTEXT",
|
||||
"bool": "BOOLEAN",
|
||||
"datetime64[ns]": "DATETIME",
|
||||
}
|
||||
|
||||
DEFAULT_SCHEMA = "mysql_ai"
|
||||
|
||||
# Misc Utilities
|
||||
|
||||
|
||||
def copy_dict(options: Optional[dict]) -> dict:
|
||||
"""
|
||||
Make a defensive copy of a dictionary, or return an empty dict if None.
|
||||
|
||||
Args:
|
||||
options: param dict or None
|
||||
|
||||
Returns:
|
||||
dict
|
||||
"""
|
||||
if options is None:
|
||||
return {}
|
||||
|
||||
return copy.deepcopy(options)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def temporary_sql_tables(
|
||||
db_connection: MySQLConnectionAbstract,
|
||||
) -> Iterator[list[tuple[str, str]]]:
|
||||
"""
|
||||
Context manager to track and automatically clean up temporary SQL tables.
|
||||
|
||||
Args:
|
||||
db_connection: Database connection object used to create and delete tables.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Yields:
|
||||
temporary_tables: List of (schema_name, table_name) tuples created during the
|
||||
context. All tables in this list are deleted on context exit.
|
||||
"""
|
||||
temporary_tables: List[Tuple[str, str]] = []
|
||||
try:
|
||||
yield temporary_tables
|
||||
finally:
|
||||
with atomic_transaction(db_connection) as cursor:
|
||||
for schema_name, table_name in temporary_tables:
|
||||
delete_sql_table(cursor, schema_name, table_name)
|
||||
|
||||
|
||||
def execute_sql(
|
||||
cursor: MySQLCursorAbstract, query: str, params: ParamsSequenceOrDictType = None
|
||||
) -> None:
|
||||
"""
|
||||
Execute an SQL query with optional parameters using the given cursor.
|
||||
|
||||
Args:
|
||||
cursor: MySQLCursorAbstract object to execute the query.
|
||||
query: SQL query string to execute.
|
||||
params: Optional sequence or dict providing parameters for the query.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If the provided SQL query/params are invalid
|
||||
If the query is valid but the sql raises as an exception
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
cursor.execute(query, params or ())
|
||||
|
||||
|
||||
def _get_name() -> str:
|
||||
"""
|
||||
Generate a random uppercase string of fixed length for table names.
|
||||
|
||||
Returns:
|
||||
Random string of length RANDOM_TABLE_NAME_LENGTH.
|
||||
"""
|
||||
char_set = string.ascii_uppercase
|
||||
return "".join(random.choices(char_set, k=RANDOM_TABLE_NAME_LENGTH))
|
||||
|
||||
|
||||
def get_random_name(condition: Callable[[str], bool], max_calls: int = 100) -> str:
|
||||
"""
|
||||
Generate a random string name that satisfies a given condition.
|
||||
|
||||
Args:
|
||||
condition: Callable that takes a generated name and returns True if it is valid.
|
||||
max_calls: Maximum number of attempts before giving up (default 100).
|
||||
|
||||
Returns:
|
||||
A random string that fulfills the provided condition.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the maximum number of attempts is reached without success.
|
||||
"""
|
||||
for _ in range(max_calls):
|
||||
if condition(name := _get_name()):
|
||||
return name
|
||||
# condition never met
|
||||
raise RuntimeError("Reached max tries without successfully finding a unique name")
|
||||
|
||||
|
||||
# Format conversions
|
||||
|
||||
|
||||
def format_value_sql(value: Any) -> Tuple[str, List[Any]]:
|
||||
"""
|
||||
Convert a Python value into its SQL-compatible string representation and parameters.
|
||||
|
||||
Args:
|
||||
value: The value to format.
|
||||
|
||||
Returns:
|
||||
Tuple containing:
|
||||
- A string for substitution into a SQL query.
|
||||
- A list of parameters to be bound into the query.
|
||||
"""
|
||||
if isinstance(value, (dict, list)):
|
||||
if len(value) == 0:
|
||||
return "%s", [None]
|
||||
return "CAST(%s as JSON)", [json.dumps(value)]
|
||||
return "%s", [value]
|
||||
|
||||
|
||||
def sql_response_to_df(cursor: MySQLCursorAbstract) -> pd.DataFrame:
|
||||
"""
|
||||
Convert the results of a cursor's last executed query to a pandas DataFrame.
|
||||
|
||||
Args:
|
||||
cursor: MySQLCursorAbstract with a completed query.
|
||||
|
||||
Returns:
|
||||
DataFrame with data from the cursor.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
If a compatible SELECT query wasn't the last statement ran
|
||||
"""
|
||||
|
||||
def _json_processor(elem: Optional[str]) -> Optional[dict]:
|
||||
return json.loads(elem) if elem is not None else None
|
||||
|
||||
def _default_processor(elem: Any) -> Any:
|
||||
return elem
|
||||
|
||||
idx_to_processor = {}
|
||||
for idx, col in enumerate(cursor.description):
|
||||
if col[1] == 245:
|
||||
# 245 is the MySQL type code for JSON
|
||||
idx_to_processor[idx] = _json_processor
|
||||
else:
|
||||
idx_to_processor[idx] = _default_processor
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# Process results
|
||||
processed_rows = []
|
||||
for row in rows:
|
||||
processed_row = list(row)
|
||||
|
||||
for idx, elem in enumerate(row):
|
||||
processed_row[idx] = idx_to_processor[idx](elem)
|
||||
|
||||
processed_rows.append(processed_row)
|
||||
|
||||
return pd.DataFrame(processed_rows, columns=cursor.column_names)
|
||||
|
||||
|
||||
def sql_table_to_df(
|
||||
cursor: MySQLCursorAbstract, schema_name: str, table_name: str
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Load the entire contents of a SQL table into a pandas DataFrame.
|
||||
|
||||
Args:
|
||||
cursor: MySQLCursorAbstract to execute the query.
|
||||
schema_name: Name of the schema containing the table.
|
||||
table_name: Name of the table to fetch.
|
||||
|
||||
Returns:
|
||||
DataFrame containing all rows from the specified table.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If the table does not exist
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError: If the schema or table name is not valid
|
||||
"""
|
||||
validate_name(schema_name)
|
||||
validate_name(table_name)
|
||||
|
||||
execute_sql(cursor, f"SELECT * FROM {schema_name}.{table_name}")
|
||||
return sql_response_to_df(cursor)
|
||||
|
||||
|
||||
# Table operations
|
||||
|
||||
|
||||
def table_exists(
|
||||
cursor: MySQLCursorAbstract, schema_name: str, table_name: str
|
||||
) -> bool:
|
||||
"""
|
||||
Check whether a table exists in a specific schema.
|
||||
|
||||
Args:
|
||||
cursor: MySQLCursorAbstract object to execute the query.
|
||||
schema_name: Name of the database schema.
|
||||
table_name: Name of the table.
|
||||
|
||||
Returns:
|
||||
True if the table exists, False otherwise.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError: If the schema or table name is not valid
|
||||
"""
|
||||
validate_name(schema_name)
|
||||
validate_name(table_name)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = %s AND table_name = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(schema_name, table_name),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def delete_sql_table(
|
||||
cursor: MySQLCursorAbstract, schema_name: str, table_name: str
|
||||
) -> None:
|
||||
"""
|
||||
Drop a table from the SQL database if it exists.
|
||||
|
||||
Args:
|
||||
cursor: MySQLCursorAbstract to execute the drop command.
|
||||
schema_name: Name of the schema.
|
||||
table_name: Name of the table to delete.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError: If the schema or table name is not valid
|
||||
"""
|
||||
validate_name(schema_name)
|
||||
validate_name(table_name)
|
||||
|
||||
execute_sql(cursor, f"DROP TABLE IF EXISTS {schema_name}.{table_name}")
|
||||
|
||||
|
||||
def extend_sql_table(
|
||||
cursor: MySQLCursorAbstract,
|
||||
schema_name: str,
|
||||
table_name: str,
|
||||
df: pd.DataFrame,
|
||||
col_name_to_placeholder_string: Dict[str, str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Insert all rows from a pandas DataFrame into an existing SQL table.
|
||||
|
||||
Args:
|
||||
cursor: MySQLCursorAbstract for execution.
|
||||
schema_name: Name of the database schema.
|
||||
table_name: Table to insert new rows into.
|
||||
df: DataFrame containing the rows to insert.
|
||||
col_name_to_placeholder_string:
|
||||
Optional mapping of column names to custom SQL value/placeholder
|
||||
strings.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If the rows could not be inserted into the table, e.g., a type or shape issue
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError: If the schema or table name is not valid
|
||||
"""
|
||||
if col_name_to_placeholder_string is None:
|
||||
col_name_to_placeholder_string = {}
|
||||
|
||||
validate_name(schema_name)
|
||||
validate_name(table_name)
|
||||
for col in df.columns:
|
||||
validate_name(str(col))
|
||||
|
||||
qualified_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
# Iterate over all rows in the DataFrame to build insert statements row by row
|
||||
for row in df.values:
|
||||
placeholders, params = [], []
|
||||
for elem, col in zip(row, df.columns):
|
||||
elem = elem.item() if hasattr(elem, "item") else elem
|
||||
|
||||
if col in col_name_to_placeholder_string:
|
||||
elem_placeholder, elem_params = col_name_to_placeholder_string[col], [
|
||||
str(elem)
|
||||
]
|
||||
else:
|
||||
elem_placeholder, elem_params = format_value_sql(elem)
|
||||
|
||||
placeholders.append(elem_placeholder)
|
||||
params.extend(elem_params)
|
||||
|
||||
cols_sql = ", ".join([str(col) for col in df.columns])
|
||||
placeholders_sql = ", ".join(placeholders)
|
||||
insert_sql = (
|
||||
f"INSERT INTO {qualified_table_name} "
|
||||
f"({cols_sql}) VALUES ({placeholders_sql})"
|
||||
)
|
||||
execute_sql(cursor, insert_sql, params=params)
|
||||
|
||||
|
||||
def sql_table_from_df(
|
||||
cursor: MySQLCursorAbstract, schema_name: str, df: pd.DataFrame
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Create a new SQL table with a random name, and populate it with data from a DataFrame.
|
||||
|
||||
If an 'id' column is defined in the dataframe, it will be used as the primary key.
|
||||
|
||||
Args:
|
||||
cursor: MySQLCursorAbstract for executing SQL.
|
||||
schema_name: Schema in which to create the table.
|
||||
df: DataFrame containing the data to be inserted.
|
||||
|
||||
Returns:
|
||||
Tuple (qualified_table_name, table_name): The schema-qualified and
|
||||
unqualified table names.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If a random available table name could not be found.
|
||||
ValueError: If any schema, table, or a column name is invalid.
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
table_name = get_random_name(
|
||||
lambda table_name: not table_exists(cursor, schema_name, table_name)
|
||||
)
|
||||
qualified_table_name = f"{schema_name}.{table_name}"
|
||||
|
||||
validate_name(schema_name)
|
||||
validate_name(table_name)
|
||||
for col in df.columns:
|
||||
validate_name(str(col))
|
||||
|
||||
columns_sql = []
|
||||
for col, dtype in df.dtypes.items():
|
||||
# Map pandas dtype to SQL type, fallback is VARCHAR
|
||||
sql_type = PD_TO_SQL_DTYPE_MAPPING.get(str(dtype), "LONGTEXT")
|
||||
validate_name(str(col))
|
||||
columns_sql.append(f"{col} {sql_type}")
|
||||
|
||||
columns_str = ", ".join(columns_sql)
|
||||
|
||||
has_id_col = any(col.lower() == "id" for col in df.columns)
|
||||
if has_id_col:
|
||||
columns_str += ", PRIMARY KEY (id)"
|
||||
|
||||
# Create table with generated columns
|
||||
create_table_sql = f"CREATE TABLE {qualified_table_name} ({columns_str})"
|
||||
execute_sql(cursor, create_table_sql)
|
||||
|
||||
try:
|
||||
# Insert provided data into new table
|
||||
extend_sql_table(cursor, schema_name, table_name, df)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
# Delete table before we lose access to it
|
||||
delete_sql_table(cursor, schema_name, table_name)
|
||||
raise
|
||||
return qualified_table_name, table_name
|
||||
|
||||
|
||||
def validate_name(name: str) -> str:
|
||||
"""
|
||||
Validate that the string is a legal SQL identifier (letters, digits, underscores).
|
||||
|
||||
Args:
|
||||
name: Name (schema, table, or column) to validate.
|
||||
|
||||
Returns:
|
||||
The validated name.
|
||||
|
||||
Raises:
|
||||
ValueError: If the name does not meet format requirements.
|
||||
"""
|
||||
# Accepts only letters, digits, and underscores; change as needed
|
||||
if not (isinstance(name, str) and re.match(r"^[A-Za-z0-9_]+$", name)):
|
||||
raise ValueError(f"Unsupported name format {name}")
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def source_schema(db_connection: MySQLConnectionAbstract) -> str:
|
||||
"""
|
||||
Retrieve the name of the currently selected schema, or set and ensure the default schema.
|
||||
|
||||
Args:
|
||||
db_connection: MySQL connector database connection object.
|
||||
|
||||
Returns:
|
||||
Name of the schema (database in use).
|
||||
|
||||
Raises:
|
||||
ValueError: If the schema name is not valid
|
||||
DatabaseError:
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
"""
|
||||
schema = db_connection.database
|
||||
if schema is None:
|
||||
schema = DEFAULT_SCHEMA
|
||||
|
||||
with atomic_transaction(db_connection) as cursor:
|
||||
create_database_stmt = f"CREATE DATABASE IF NOT EXISTS {schema}"
|
||||
execute_sql(cursor, create_database_stmt)
|
||||
|
||||
validate_name(schema)
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def is_table_empty(
|
||||
cursor: MySQLCursorAbstract, schema_name: str, table_name: str
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if a given SQL table is empty.
|
||||
|
||||
Args:
|
||||
cursor: MySQLCursorAbstract with access to the database.
|
||||
schema_name: Name of the schema containing the table.
|
||||
table_name: Name of the table to check.
|
||||
|
||||
Returns:
|
||||
True if the table has no rows, False otherwise.
|
||||
|
||||
Raises:
|
||||
DatabaseError:
|
||||
If the table does not exist
|
||||
If a database connection issue occurs.
|
||||
If an operational error occurs during execution.
|
||||
ValueError: If the schema or table name is not valid
|
||||
"""
|
||||
validate_name(schema_name)
|
||||
validate_name(table_name)
|
||||
|
||||
cursor.execute(f"SELECT 1 FROM {schema_name}.{table_name} LIMIT 1")
|
||||
return cursor.fetchone() is None
|
||||
|
||||
|
||||
def convert_to_df(
|
||||
arr: Optional[Union[pd.DataFrame, pd.Series, np.ndarray]],
|
||||
col_prefix: str = "feature",
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""
|
||||
Convert input data to a pandas DataFrame if necessary.
|
||||
|
||||
Args:
|
||||
arr: Input data as a pandas DataFrame, NumPy ndarray, pandas Series, or None.
|
||||
|
||||
Returns:
|
||||
If the input is None, returns None.
|
||||
Otherwise, returns a DataFrame backed by the same underlying data whenever
|
||||
possible (except in cases where pandas or NumPy must copy, such as for
|
||||
certain views or non-contiguous arrays).
|
||||
|
||||
Notes:
|
||||
- If an ndarray is passed, column names will be integer indices (0, 1, ...).
|
||||
- If a DataFrame is passed, column names and indices are preserved.
|
||||
- The returned DataFrame is a shallow copy and shares data with the original
|
||||
input when possible; however, copies may still occur for certain input
|
||||
types or memory layouts.
|
||||
"""
|
||||
if arr is None:
|
||||
return None
|
||||
|
||||
if isinstance(arr, pd.DataFrame):
|
||||
return pd.DataFrame(arr)
|
||||
if isinstance(arr, pd.Series):
|
||||
return arr.to_frame()
|
||||
|
||||
if arr.ndim == 1:
|
||||
arr = arr.reshape(-1, 1)
|
||||
col_names = [f"{col_prefix}_{idx}" for idx in range(arr.shape[1])]
|
||||
|
||||
return pd.DataFrame(arr, columns=col_names, copy=False)
|
||||
@@ -0,0 +1,127 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""MySQL Connector/Python - MySQL driver written in Python."""
|
||||
|
||||
try:
|
||||
from .connection_cext import CMySQLConnection
|
||||
except ImportError:
|
||||
HAVE_CEXT = False
|
||||
else:
|
||||
HAVE_CEXT = True
|
||||
|
||||
|
||||
from . import version
|
||||
from .connection import MySQLConnection
|
||||
from .constants import CharacterSet, ClientFlag, FieldFlag, FieldType, RefreshOption
|
||||
from .dbapi import (
|
||||
BINARY,
|
||||
DATETIME,
|
||||
NUMBER,
|
||||
ROWID,
|
||||
STRING,
|
||||
Binary,
|
||||
Date,
|
||||
DateFromTicks,
|
||||
Time,
|
||||
TimeFromTicks,
|
||||
Timestamp,
|
||||
TimestampFromTicks,
|
||||
apilevel,
|
||||
paramstyle,
|
||||
threadsafety,
|
||||
)
|
||||
from .errors import ( # pylint: disable=redefined-builtin
|
||||
DatabaseError,
|
||||
DataError,
|
||||
Error,
|
||||
IntegrityError,
|
||||
InterfaceError,
|
||||
InternalError,
|
||||
NotSupportedError,
|
||||
OperationalError,
|
||||
PoolError,
|
||||
ProgrammingError,
|
||||
Warning,
|
||||
custom_error_exception,
|
||||
)
|
||||
from .pooling import connect
|
||||
|
||||
Connect = connect
|
||||
|
||||
__version_info__ = version.VERSION
|
||||
"""This attribute indicates the Connector/Python version as an array
|
||||
of version components."""
|
||||
|
||||
__version__ = version.VERSION_TEXT
|
||||
"""This attribute indicates the Connector/Python version as a string."""
|
||||
|
||||
__all__ = [
|
||||
"MySQLConnection",
|
||||
"Connect",
|
||||
"custom_error_exception",
|
||||
# Some useful constants
|
||||
"FieldType",
|
||||
"FieldFlag",
|
||||
"ClientFlag",
|
||||
"CharacterSet",
|
||||
"RefreshOption",
|
||||
"HAVE_CEXT",
|
||||
# Error handling
|
||||
"Error",
|
||||
"Warning",
|
||||
"InterfaceError",
|
||||
"DatabaseError",
|
||||
"NotSupportedError",
|
||||
"DataError",
|
||||
"IntegrityError",
|
||||
"PoolError",
|
||||
"ProgrammingError",
|
||||
"OperationalError",
|
||||
"InternalError",
|
||||
# DBAPI PEP 249 required exports
|
||||
"connect",
|
||||
"apilevel",
|
||||
"threadsafety",
|
||||
"paramstyle",
|
||||
"Date",
|
||||
"Time",
|
||||
"Timestamp",
|
||||
"Binary",
|
||||
"DateFromTicks",
|
||||
"DateFromTicks",
|
||||
"TimestampFromTicks",
|
||||
"TimeFromTicks",
|
||||
"STRING",
|
||||
"BINARY",
|
||||
"NUMBER",
|
||||
"DATETIME",
|
||||
"ROWID",
|
||||
# C Extension
|
||||
"CMySQLConnection",
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Decorators Hub."""
|
||||
|
||||
import functools
|
||||
import warnings
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from .constants import RefreshOption
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
def cmd_refresh_verify_options() -> Callable:
|
||||
"""Decorator verifying which options are relevant and which aren't based on
|
||||
the server version the client is connecting to."""
|
||||
|
||||
def decorator(cmd_refresh: Callable) -> Callable:
|
||||
@functools.wraps(cmd_refresh)
|
||||
def wrapper(
|
||||
cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any
|
||||
) -> Callable:
|
||||
options: int = args[0]
|
||||
if (options & RefreshOption.GRANT) and cnx.server_version >= (
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
):
|
||||
warnings.warn(
|
||||
"As of MySQL Server 9.2.0, refreshing grant tables is not needed "
|
||||
"if you use statements GRANT, REVOKE, CREATE, DROP, or ALTER. "
|
||||
"You should expect this option to be unsupported in a future "
|
||||
"version of MySQL Connector/Python when MySQL Server removes it.",
|
||||
category=DeprecationWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
return cmd_refresh(cnx, options, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def handle_read_write_timeout() -> Callable:
|
||||
"""
|
||||
Decorator to close the current connection if a read or a write timeout
|
||||
is raised by the method passed via the func parameter.
|
||||
"""
|
||||
|
||||
def decorator(cnx_method: Callable) -> Callable:
|
||||
@functools.wraps(cnx_method)
|
||||
def handle_cnx_method(
|
||||
cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
try:
|
||||
return cnx_method(cnx, *args, **kwargs)
|
||||
except Exception as err:
|
||||
if isinstance(err, TimeoutError):
|
||||
cnx.close()
|
||||
raise err
|
||||
|
||||
return handle_cnx_method
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def deprecated(reason: str) -> Callable:
|
||||
"""Use it to decorate deprecated methods."""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Callable:
|
||||
warnings.warn(
|
||||
f"Call to deprecated function {func.__name__}. Reason: {reason}",
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,406 @@
|
||||
# Copyright (c) 2024, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Classes and methods utilized to work with MySQL Scripts."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from collections import deque
|
||||
from typing import Deque, Generator, Optional
|
||||
|
||||
from .errors import InterfaceError
|
||||
from .types import MySQLScriptPartition
|
||||
|
||||
DEFAULT_DELIMITER = b";"
|
||||
"""The default delimiter of MySQL Client and the only one
|
||||
recognized by the MySQL server protocol."""
|
||||
|
||||
DELIMITER_RESERVED_SYMBOLS = {
|
||||
"$": rb"\$",
|
||||
"^": rb"\^",
|
||||
"?": rb"\?",
|
||||
"(": rb"\(",
|
||||
")": rb"\)",
|
||||
"[": rb"\[",
|
||||
"]": rb"\]",
|
||||
"{": rb"\{",
|
||||
"}": rb"\}",
|
||||
".": rb"\.",
|
||||
"|": rb"\|",
|
||||
"+": rb"\+",
|
||||
"-": rb"\-",
|
||||
"*": rb"\*",
|
||||
}
|
||||
"""Symbols with a special meaning in regular expression contexts."""
|
||||
|
||||
DELIMITER_PATTERN: re.Pattern = re.compile(
|
||||
rb"""(delimiter\s+)(?=(?:[^"'`]*(?:"[^"]*"|'[^']*'|`[^`]*`))*[^"'`]*$)""",
|
||||
flags=re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
"""Regular expression pattern recognizing the delimiter command."""
|
||||
|
||||
|
||||
class MySQLScriptSplitter:
|
||||
"""Breaks a MySQL script into single statements.
|
||||
|
||||
It strips custom delimiters and comments along the way, except for comments
|
||||
representing a MySQL extension or optimizer hint.
|
||||
"""
|
||||
|
||||
_regex_sql_split_stmts = b"""(?=(?:[^"'`]*(?:"[^"]*"|'[^']*'|`[^`]*`))*[^"'`]*$)"""
|
||||
|
||||
def __init__(self, sql_script: bytes) -> None:
|
||||
"""Constructor."""
|
||||
self._code = sql_script
|
||||
self._single_stmts: Optional[list[bytes]] = None
|
||||
self._mappable_stmts: Optional[list[bytes]] = None
|
||||
self._re_sql_split_stmts: dict[bytes, re.Pattern] = {}
|
||||
|
||||
def _split_statement(self, code: bytes, delimiter: bytes) -> list[bytes]:
|
||||
"""Split code context by delimiter."""
|
||||
snippets = []
|
||||
|
||||
if delimiter not in self._re_sql_split_stmts:
|
||||
if b"\\" in delimiter:
|
||||
raise InterfaceError(
|
||||
"The backslash (\\) character is not a valid delimiter."
|
||||
)
|
||||
delimiter_pattern = [
|
||||
DELIMITER_RESERVED_SYMBOLS.get(char, char.encode())
|
||||
for char in delimiter.decode()
|
||||
]
|
||||
self._re_sql_split_stmts[delimiter] = re.compile(
|
||||
b"".join(delimiter_pattern) + self._regex_sql_split_stmts
|
||||
)
|
||||
|
||||
for snippet in self._re_sql_split_stmts[delimiter].split(code):
|
||||
snippet_strip = snippet.strip()
|
||||
if snippet_strip:
|
||||
snippets.append(snippet_strip)
|
||||
|
||||
return snippets
|
||||
|
||||
@staticmethod
|
||||
def is_white_space_char(char: int) -> bool:
|
||||
"""Validates whether `char` is a white-space character or not."""
|
||||
return unicodedata.category(chr(char))[0] in {"Z"}
|
||||
|
||||
@staticmethod
|
||||
def is_control_char(char: int) -> bool:
|
||||
"""Validates whether `char` is a control character or not."""
|
||||
return unicodedata.category(chr(char))[0] in {"C"}
|
||||
|
||||
@staticmethod
|
||||
def split_by_control_char_or_white_space(string: bytes) -> list[bytes]:
|
||||
"""Split `string` by any control character or whitespace."""
|
||||
return re.split(rb"[\s\x00-\x1f\x7f-\x9f]", string)
|
||||
|
||||
@staticmethod
|
||||
def has_delimiter(code: bytes) -> bool:
|
||||
"""Validates whether `code` has the delimiter command pattern or not."""
|
||||
return re.search(DELIMITER_PATTERN, code) is not None
|
||||
|
||||
@staticmethod
|
||||
def remove_comments(code: bytes) -> bytes:
|
||||
"""Remove MySQL comments which include `--`-style, `#`-style
|
||||
and `C`-style comments.
|
||||
|
||||
A `--`-style comment spans from `--` to the end of the line.
|
||||
It requires the second dash to be
|
||||
followed by at least one whitespace or control character
|
||||
(such as a space, tab, newline, and so on).
|
||||
|
||||
A `#`-style comment spans from `#` to the end of the line.
|
||||
|
||||
A C-style comment spans from a `/*` sequence to the following `*/`
|
||||
sequence, as in the C programming language. This syntax enables a
|
||||
comment to extend over multiple lines because the beginning and
|
||||
closing sequences need not be on the same line.
|
||||
|
||||
**NOTE: Only C-style comments representing MySQL extensions or
|
||||
optimizer hints are preserved**. E.g.,
|
||||
|
||||
```
|
||||
/*! MySQL-specific code */
|
||||
|
||||
/*+ MySQL-specific code */
|
||||
```
|
||||
|
||||
*For Reference Manual- MySQL Comments*, see
|
||||
https://dev.mysql.com/doc/refman/en/comments.html.
|
||||
"""
|
||||
|
||||
def is_dash_style(b_str: bytes, b_char: int) -> bool:
|
||||
return b_str == b"--" and (
|
||||
MySQLScriptSplitter.is_control_char(b_char)
|
||||
or MySQLScriptSplitter.is_white_space_char(b_char)
|
||||
)
|
||||
|
||||
def is_hash_style(b_str: bytes) -> bool:
|
||||
return b_str == b"#"
|
||||
|
||||
def is_c_style(b_str: bytes, b_char: int) -> bool:
|
||||
return b_str == b"/*" and b_char not in {ord("!"), ord("+")}
|
||||
|
||||
buf = bytearray(b"")
|
||||
i, literal_ctx = 0, None
|
||||
line_break, single_quote, double_quote = ord("\n"), ord("'"), ord('"')
|
||||
while i < len(code):
|
||||
if literal_ctx is None:
|
||||
style = None
|
||||
if is_dash_style(buf[-2:], code[i]):
|
||||
style = "--"
|
||||
elif is_hash_style(buf[-1:]):
|
||||
style = "#"
|
||||
elif is_c_style(buf[-2:], code[i]):
|
||||
style = "/*"
|
||||
if style is not None:
|
||||
if style in ("--", "#"):
|
||||
while i < len(code) and code[i] != line_break:
|
||||
i += 1
|
||||
else:
|
||||
while i + 1 < len(code) and code[i : i + 2] != b"*/":
|
||||
i += 1
|
||||
i += 2
|
||||
|
||||
for _ in range(len(style)):
|
||||
buf.pop()
|
||||
|
||||
while buf and (
|
||||
MySQLScriptSplitter.is_control_char(buf[-1])
|
||||
or MySQLScriptSplitter.is_white_space_char(buf[-1])
|
||||
):
|
||||
buf.pop()
|
||||
|
||||
continue
|
||||
|
||||
if literal_ctx is None and code[i] in [single_quote, double_quote]:
|
||||
literal_ctx = code[i]
|
||||
elif literal_ctx is not None and code[i] in {literal_ctx, line_break}:
|
||||
literal_ctx = None
|
||||
|
||||
buf.append(code[i])
|
||||
i += 1
|
||||
|
||||
return bytes(buf)
|
||||
|
||||
def split_script(self, remove_comments: bool = True) -> list[bytes]:
|
||||
"""Splits the given script text into a sequence of individual statements.
|
||||
|
||||
The word DELIMITER and any of its lower and upper case combinations
|
||||
such as delimiter, DeLiMiter, etc., are considered reserved words by
|
||||
the connector. Users must quote these when included in multi statements
|
||||
for other purposes different from declaring an actual statement delimiter;
|
||||
e.g., as names for tables, columns, variables, in comments, etc.
|
||||
|
||||
```
|
||||
CREATE TABLE `delimiter` (begin INT, end INT); -- I am a `DELimiTer` comment
|
||||
```
|
||||
|
||||
If they are not quoted, the statement-mapping will not produce the expected
|
||||
experience.
|
||||
|
||||
See https://dev.mysql.com/doc/refman/8.0/en/keywords.html to know more
|
||||
about quoting a reserved word.
|
||||
|
||||
*Note that comments are always ignored as they are not considered to be
|
||||
part of statements, with one exeception; **C-style comments representing
|
||||
MySQL extensions or optimizer hints are preserved***.
|
||||
"""
|
||||
# If it was already computed, then skip computation and use the cache
|
||||
if self._single_stmts is not None:
|
||||
return self._single_stmts
|
||||
|
||||
# initialize variables
|
||||
self._single_stmts = []
|
||||
delimiter = DEFAULT_DELIMITER
|
||||
buf: list[bytes] = []
|
||||
prev = b""
|
||||
|
||||
# remove comments
|
||||
if remove_comments:
|
||||
code = MySQLScriptSplitter.remove_comments(code=self._code)
|
||||
else:
|
||||
code = self._code
|
||||
|
||||
# let's split the script by `delimiter pattern` - the pattern is also
|
||||
# included in the returned list.
|
||||
for curr in re.split(pattern=DELIMITER_PATTERN, string=code):
|
||||
# Checking if the previous substring is a "switch of context
|
||||
# (delimiter)" point.
|
||||
if re.search(DELIMITER_PATTERN, prev):
|
||||
# The next delimiter must be the sequence of chars until
|
||||
# reaching a control char or whitespace
|
||||
next_delimiter = self.split_by_control_char_or_white_space(curr)[0]
|
||||
|
||||
# We shall remove the delimiter command from the code
|
||||
buf.pop()
|
||||
|
||||
# At this point buf includes all the code where `delimiter` applies.
|
||||
self._single_stmts.extend(
|
||||
self._split_statement(code=b" ".join(buf), delimiter=delimiter)
|
||||
)
|
||||
|
||||
# From the current substring, let's take everything but the
|
||||
# "next delimiter" portion. Also, let's update the delimiter
|
||||
delimiter, buf = next_delimiter, [curr[len(next_delimiter) :]]
|
||||
else:
|
||||
# Let's accumulate
|
||||
buf.append(curr)
|
||||
|
||||
# track the previous substring
|
||||
prev = curr
|
||||
|
||||
# Ensure there are no loose ends
|
||||
if buf:
|
||||
self._single_stmts.extend(
|
||||
self._split_statement(code=b" ".join(buf), delimiter=delimiter)
|
||||
)
|
||||
|
||||
return self._single_stmts
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self._code.decode("utf-8")
|
||||
|
||||
|
||||
def split_multi_statement(
|
||||
sql_code: bytes,
|
||||
map_results: bool = False,
|
||||
) -> Generator[MySQLScriptPartition, None, None]:
|
||||
"""Breaks a MySQL script into sub-scripts.
|
||||
|
||||
If the given script uses `DELIMITER` statements (which are not recognized
|
||||
by MySQL Server), the connector will parse such statements to remove them
|
||||
from the script and substitute delimiters as needed. This pre-processing
|
||||
may cause a performance hit when using long scripts. Note that when enabling
|
||||
`map_results`, the script is expected to use `DELIMITER` statements in order
|
||||
to split the script into multiple query strings.
|
||||
|
||||
Args:
|
||||
sql_code: MySQL script.
|
||||
map_results: If True, each sub-script is `statement-result` mappable.
|
||||
|
||||
Returns:
|
||||
A generator of typed dictionaries with keys `single_stmts` and `mappable_stmts`.
|
||||
|
||||
If mapping disabled and no delimiters detected, it returns a 1-item generator,
|
||||
the field `single_stmts` is an empty list and the `mappable_stmt` field
|
||||
corresponds to the unmodified script, that may be mappable.
|
||||
|
||||
If mapping disabled and delimiters detected, it returns a 1-item generator,
|
||||
the field `single_stmts` is a list including all the single statements
|
||||
found in the script and the `mappable_stmt` field corresponds to the processed
|
||||
script (delimiters are stripped) that may be mappable.
|
||||
|
||||
If maping enabled, the script is broken into mappable partitions. It returns
|
||||
an N-item generator (as many items as computed partitions), the field
|
||||
`single_stmts` is a list including all the single statements of the partition
|
||||
and the `mappable_stmt` field corresponds to the sub-script (partition) that
|
||||
is guaranteed to be mappable.
|
||||
|
||||
Raises:
|
||||
`InterfaceError` if an invalid delimiter string is found.
|
||||
"""
|
||||
if not MySQLScriptSplitter.has_delimiter(sql_code) and not map_results:
|
||||
# For those users executing single statements or scripts with no delimiters,
|
||||
# they can get a performance boost by bypassing the multi statement splitter.
|
||||
|
||||
# Simply wrap the multi statement up (so it can be processed correctly
|
||||
# downstream) and return it as it is.
|
||||
yield MySQLScriptPartition(single_stmts=deque([]), mappable_stmt=sql_code)
|
||||
return
|
||||
|
||||
tok = MySQLScriptSplitter(sql_script=sql_code)
|
||||
|
||||
# The splitter splits the sql code into many single statements
|
||||
# while also getting rid of the delimiters (if any).
|
||||
stmts = tok.split_script()
|
||||
|
||||
# if there are not statements to execute
|
||||
if not stmts:
|
||||
# Simply wrap the multi statement up (so it can be processed correctly
|
||||
# downstream).
|
||||
yield MySQLScriptPartition(single_stmts=deque([b""]), mappable_stmt=b"")
|
||||
return
|
||||
|
||||
if not map_results:
|
||||
# group single statements into a unique and possibly no mappable
|
||||
# multi statement.
|
||||
yield MySQLScriptPartition(
|
||||
single_stmts=deque(stmts), mappable_stmt=b";\n".join(stmts)
|
||||
)
|
||||
return
|
||||
|
||||
# group single statements into one or more mappable multi statements.
|
||||
i = 0
|
||||
partition_ids = (j for j, stmt in enumerate(stmts) if stmt[:5].upper() == b"CALL ")
|
||||
for j in partition_ids:
|
||||
if j > i:
|
||||
yield (
|
||||
MySQLScriptPartition(
|
||||
mappable_stmt=b";\n".join(stmts[i:j]),
|
||||
single_stmts=deque(stmts[i:j]),
|
||||
)
|
||||
)
|
||||
yield MySQLScriptPartition(
|
||||
mappable_stmt=stmts[j], single_stmts=deque([stmts[j]])
|
||||
)
|
||||
i = j + 1
|
||||
|
||||
if i < len(stmts):
|
||||
yield (
|
||||
MySQLScriptPartition(
|
||||
mappable_stmt=b";\n".join(stmts[i : len(stmts)]),
|
||||
single_stmts=deque(stmts[i : len(stmts)]),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_local_infile_filenames(script: bytes) -> Deque[str]:
|
||||
"""Scans the MySQL script looking for `filenames` (one for each
|
||||
`LOCAL INFILE` statement found).
|
||||
|
||||
Arguments:
|
||||
script: a MySQL script that may include one or more `LOCAL INFILE` statements.
|
||||
|
||||
Returns:
|
||||
filenames: a list of filenames (one for each `LOCAL INFILE` statement found).
|
||||
An empty list is returned if no matches are found.
|
||||
"""
|
||||
matches = re.findall(
|
||||
pattern=rb"""LOCAL\s+INFILE\s+(["'])((?:\\\1|(?:(?!\1)).)*)(\1)""",
|
||||
string=MySQLScriptSplitter.remove_comments(script),
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if not matches or len(matches[0]) != 3:
|
||||
return deque([])
|
||||
|
||||
# If there is a match, we get ("'", "filename", "'") , that's to say,
|
||||
# the 1st and 3rd entries are the quote symbols, and the 2nd the actual filename.
|
||||
return deque([match[1].decode("utf-8") for match in matches])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""MySQL Connector/Python - MySQL driver written in Python."""
|
||||
|
||||
from .connection import MySQLConnection, MySQLConnectionAbstract
|
||||
from .pooling import MySQLConnectionPool, PooledMySQLConnection, connect
|
||||
|
||||
__all__ = [
|
||||
"MySQLConnection",
|
||||
"connect",
|
||||
"MySQLConnectionAbstract",
|
||||
"MySQLConnectionPool",
|
||||
"PooledMySQLConnection",
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Decorators Hub."""
|
||||
|
||||
import functools
|
||||
import warnings
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from ..constants import RefreshOption
|
||||
from ..errors import ReadTimeoutError, WriteTimeoutError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
def cmd_refresh_verify_options() -> Callable:
|
||||
"""Decorator verifying which options are relevant and which aren't based on
|
||||
the server version the client is connecting to."""
|
||||
|
||||
def decorator(cmd_refresh: Callable) -> Callable:
|
||||
@functools.wraps(cmd_refresh)
|
||||
async def wrapper(
|
||||
cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any
|
||||
) -> Callable:
|
||||
options: int = args[0]
|
||||
if (options & RefreshOption.GRANT) and cnx.server_version >= (
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
):
|
||||
warnings.warn(
|
||||
"As of MySQL Server 9.2.0, refreshing grant tables is not needed "
|
||||
"if you use statements GRANT, REVOKE, CREATE, DROP, or ALTER. "
|
||||
"You should expect this option to be unsupported in a future "
|
||||
"version of MySQL Connector/Python when MySQL Server removes it.",
|
||||
category=DeprecationWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
return await cmd_refresh(cnx, options, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def deprecated(reason: str) -> Callable:
|
||||
"""Use it to decorate deprecated methods."""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Callable:
|
||||
warnings.warn(
|
||||
f"Call to deprecated function {func.__name__}. Reason: {reason}",
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def handle_read_write_timeout() -> Callable:
|
||||
"""
|
||||
Decorator to close the current connection if a read or a write timeout
|
||||
is raised by the method passed via the func parameter.
|
||||
"""
|
||||
|
||||
def decorator(cnx_method: Callable) -> Callable:
|
||||
@functools.wraps(cnx_method)
|
||||
async def handle_cnx_method(
|
||||
cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
try:
|
||||
return await cnx_method(cnx, *args, **kwargs)
|
||||
except Exception as err:
|
||||
if isinstance(err, (ReadTimeoutError, WriteTimeoutError)):
|
||||
await cnx.close()
|
||||
raise err
|
||||
|
||||
return handle_cnx_method
|
||||
|
||||
return decorator
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Implementing support for MySQL Authentication Plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["MySQLAuthenticator"]
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from ..errors import InterfaceError, NotSupportedError, get_exception
|
||||
from ..protocol import (
|
||||
AUTH_SWITCH_STATUS,
|
||||
DEFAULT_CHARSET_ID,
|
||||
DEFAULT_MAX_ALLOWED_PACKET,
|
||||
ERR_STATUS,
|
||||
EXCHANGE_FURTHER_STATUS,
|
||||
MFA_STATUS,
|
||||
OK_STATUS,
|
||||
)
|
||||
from ..types import HandShakeType
|
||||
from .logger import logger
|
||||
from .plugins import MySQLAuthPlugin, get_auth_plugin
|
||||
from .protocol import MySQLProtocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .network import MySQLSocket
|
||||
|
||||
|
||||
class MySQLAuthenticator:
|
||||
"""Implements the authentication phase."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Constructor."""
|
||||
self._username: str = ""
|
||||
self._passwords: Dict[int, str] = {}
|
||||
self._plugin_config: Dict[str, Any] = {}
|
||||
self._ssl_enabled: bool = False
|
||||
self._auth_strategy: Optional[MySQLAuthPlugin] = None
|
||||
self._auth_plugin_class: Optional[str] = None
|
||||
|
||||
@property
|
||||
def ssl_enabled(self) -> bool:
|
||||
"""Signals whether or not SSL is enabled."""
|
||||
return self._ssl_enabled
|
||||
|
||||
@property
|
||||
def plugin_config(self) -> Dict[str, Any]:
|
||||
"""Custom arguments that are being provided to the authentication plugin.
|
||||
|
||||
The parameters defined here will override the ones defined in the
|
||||
auth plugin itself.
|
||||
|
||||
The plugin config is a read-only property - the plugin configuration
|
||||
provided when invoking `authenticate()` is recorded and can be queried
|
||||
by accessing this property.
|
||||
|
||||
Returns:
|
||||
dict: The latest plugin configuration provided when invoking
|
||||
`authenticate()`.
|
||||
"""
|
||||
return self._plugin_config
|
||||
|
||||
def update_plugin_config(self, config: Dict[str, Any]) -> None:
|
||||
"""Update the 'plugin_config' instance variable"""
|
||||
self._plugin_config.update(config)
|
||||
|
||||
def _switch_auth_strategy(
|
||||
self,
|
||||
new_strategy_name: str,
|
||||
strategy_class: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password_factor: int = 1,
|
||||
) -> None:
|
||||
"""Switch the authorization plugin.
|
||||
|
||||
Args:
|
||||
new_strategy_name: New authorization plugin name to switch to.
|
||||
strategy_class: New authorization plugin class to switch to
|
||||
(has higher precedence than the authorization plugin name).
|
||||
username: Username to be used - if not defined, the username
|
||||
provided when `authentication()` was invoked is used.
|
||||
password_factor: Up to three levels of authentication (MFA) are allowed,
|
||||
hence you can choose the password corresponding to the 1st,
|
||||
2nd, or 3rd factor - 1st is the default.
|
||||
"""
|
||||
if username is None:
|
||||
username = self._username
|
||||
|
||||
if strategy_class is None:
|
||||
strategy_class = self._auth_plugin_class
|
||||
|
||||
logger.debug("Switching to strategy %s", new_strategy_name)
|
||||
self._auth_strategy = get_auth_plugin(
|
||||
plugin_name=new_strategy_name, auth_plugin_class=strategy_class
|
||||
)(
|
||||
username,
|
||||
self._passwords.get(password_factor, ""),
|
||||
ssl_enabled=self.ssl_enabled,
|
||||
)
|
||||
|
||||
async def _mfa_n_factor(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
pkt: bytes,
|
||||
) -> Optional[bytes]:
|
||||
"""Handle MFA (Multi-Factor Authentication) response.
|
||||
|
||||
Up to three levels of authentication (MFA) are allowed.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
pkt: MFA response.
|
||||
|
||||
Returns:
|
||||
ok_packet: If last server's response is an OK packet.
|
||||
None: If last server's response isn't an OK packet and no ERROR was raised.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If got an invalid N factor.
|
||||
errors.ErrorTypes: If got an ERROR response.
|
||||
"""
|
||||
n_factor = 2
|
||||
while pkt[4] == MFA_STATUS:
|
||||
if n_factor not in self._passwords:
|
||||
raise InterfaceError(
|
||||
"Failed Multi Factor Authentication (invalid N factor)"
|
||||
)
|
||||
|
||||
new_strategy_name, auth_data = MySQLProtocol.parse_auth_next_factor(pkt)
|
||||
self._switch_auth_strategy(new_strategy_name, password_factor=n_factor)
|
||||
logger.debug("MFA %i factor %s", n_factor, self._auth_strategy.name)
|
||||
|
||||
pkt = await self._auth_strategy.auth_switch_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == EXCHANGE_FURTHER_STATUS:
|
||||
auth_data = MySQLProtocol.parse_auth_more_data(pkt)
|
||||
pkt = await self._auth_strategy.auth_more_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == OK_STATUS:
|
||||
logger.debug("MFA completed succesfully")
|
||||
return pkt
|
||||
|
||||
if pkt[4] == ERR_STATUS:
|
||||
raise get_exception(pkt)
|
||||
|
||||
n_factor += 1
|
||||
|
||||
logger.warning("MFA terminated with a no ok packet")
|
||||
return None
|
||||
|
||||
async def _handle_server_response(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
pkt: bytes,
|
||||
) -> Optional[bytes]:
|
||||
"""Handle server's response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
pkt: Server's response after completing the `HandShakeResponse`.
|
||||
|
||||
Returns:
|
||||
ok_packet: If last server's response is an OK packet.
|
||||
None: If last server's response isn't an OK packet and no ERROR was raised.
|
||||
|
||||
Raises:
|
||||
errors.ErrorTypes: If got an ERROR response.
|
||||
NotSupportedError: If got Authentication with old (insecure) passwords.
|
||||
"""
|
||||
if pkt[4] == AUTH_SWITCH_STATUS and len(pkt) == 5:
|
||||
raise NotSupportedError(
|
||||
"Authentication with old (insecure) passwords "
|
||||
"is not supported. For more information, lookup "
|
||||
"Password Hashing in the latest MySQL manual"
|
||||
)
|
||||
|
||||
if pkt[4] == AUTH_SWITCH_STATUS:
|
||||
logger.debug("Server's response is an auth switch request")
|
||||
new_strategy_name, auth_data = MySQLProtocol.parse_auth_switch_request(pkt)
|
||||
self._switch_auth_strategy(new_strategy_name)
|
||||
pkt = await self._auth_strategy.auth_switch_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == EXCHANGE_FURTHER_STATUS:
|
||||
logger.debug("Exchanging further packets")
|
||||
auth_data = MySQLProtocol.parse_auth_more_data(pkt)
|
||||
pkt = await self._auth_strategy.auth_more_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == OK_STATUS:
|
||||
logger.debug("%s completed succesfully", self._auth_strategy.name)
|
||||
return pkt
|
||||
|
||||
if pkt[4] == MFA_STATUS:
|
||||
logger.debug("Starting multi-factor authentication")
|
||||
logger.debug("MFA 1 factor %s", self._auth_strategy.name)
|
||||
return await self._mfa_n_factor(sock, pkt)
|
||||
|
||||
if pkt[4] == ERR_STATUS:
|
||||
raise get_exception(pkt)
|
||||
|
||||
return None
|
||||
|
||||
async def authenticate(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
handshake: HandShakeType,
|
||||
username: str = "",
|
||||
password1: str = "",
|
||||
password2: str = "",
|
||||
password3: str = "",
|
||||
database: Optional[str] = None,
|
||||
charset: int = DEFAULT_CHARSET_ID,
|
||||
client_flags: int = 0,
|
||||
ssl_enabled: bool = False,
|
||||
max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET,
|
||||
auth_plugin: Optional[str] = None,
|
||||
auth_plugin_class: Optional[str] = None,
|
||||
conn_attrs: Optional[Dict[str, str]] = None,
|
||||
is_change_user_request: bool = False,
|
||||
read_timeout: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> bytes:
|
||||
"""Perform the authentication phase.
|
||||
|
||||
During re-authentication you must set `is_change_user_request` to True.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
handshake: Initial handshake.
|
||||
username: Account's username.
|
||||
password1: Account's password factor 1.
|
||||
password2: Account's password factor 2.
|
||||
password3: Account's password factor 3.
|
||||
database: Initial database name for the connection.
|
||||
charset: Client charset (see [1]), only the lower 8-bits.
|
||||
client_flags: Integer representing client capabilities flags.
|
||||
ssl_enabled: Boolean indicating whether SSL is enabled,
|
||||
max_allowed_packet: Maximum packet size.
|
||||
auth_plugin: Authorization plugin name.
|
||||
auth_plugin_class: Authorization plugin class (has higher precedence
|
||||
than the authorization plugin name).
|
||||
conn_attrs: Connection attributes.
|
||||
is_change_user_request: Whether is a `change user request` operation or not.
|
||||
read_timeout: Timeout in seconds upto which the connector should wait for
|
||||
the server to reply back before raising an ReadTimeoutError.
|
||||
write_timeout: Timeout in seconds upto which the connector should spend to
|
||||
send data to the server before raising an WriteTimeoutError.
|
||||
|
||||
Returns:
|
||||
ok_packet: OK packet.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If OK packet is NULL.
|
||||
ReadTimeoutError: If the time taken for the server to reply back exceeds
|
||||
'read_timeout' (if set).
|
||||
WriteTimeoutError: If the time taken to send data packets to the server
|
||||
exceeds 'write_timeout' (if set).
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\
|
||||
page_protocol_basic_character_set.html#a_protocol_character_set
|
||||
"""
|
||||
# update credentials, plugin config and plugin class
|
||||
self._username = username
|
||||
self._passwords = {1: password1, 2: password2, 3: password3}
|
||||
self._ssl_enabled = ssl_enabled
|
||||
self._auth_plugin_class = auth_plugin_class
|
||||
|
||||
# client's handshake response
|
||||
response_payload, self._auth_strategy = MySQLProtocol.make_auth(
|
||||
handshake=handshake,
|
||||
username=username,
|
||||
password=password1,
|
||||
database=database,
|
||||
charset=charset,
|
||||
client_flags=client_flags,
|
||||
max_allowed_packet=max_allowed_packet,
|
||||
auth_plugin=auth_plugin,
|
||||
auth_plugin_class=auth_plugin_class,
|
||||
conn_attrs=conn_attrs,
|
||||
is_change_user_request=is_change_user_request,
|
||||
ssl_enabled=self.ssl_enabled,
|
||||
plugin_config=self.plugin_config,
|
||||
)
|
||||
|
||||
# client sends transaction response
|
||||
send_args = (
|
||||
(0, 0, write_timeout)
|
||||
if is_change_user_request
|
||||
else (None, None, write_timeout)
|
||||
)
|
||||
await sock.write(response_payload, *send_args)
|
||||
|
||||
# server replies back
|
||||
pkt = bytes(await sock.read(read_timeout))
|
||||
|
||||
ok_pkt = await self._handle_server_response(sock, pkt)
|
||||
if ok_pkt is None:
|
||||
raise InterfaceError("Got a NULL ok_pkt") from None
|
||||
|
||||
return ok_pkt
|
||||
@@ -0,0 +1,686 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""This module contains the MySQL Server Character Sets."""
|
||||
|
||||
__all__ = ["Charset", "charsets"]
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import DefaultDict, Dict, Optional, Sequence, Tuple
|
||||
|
||||
from ..errors import ProgrammingError
|
||||
|
||||
|
||||
@dataclass
|
||||
class Charset:
|
||||
"""Dataclass representing a character set."""
|
||||
|
||||
charset_id: int
|
||||
name: str
|
||||
collation: str
|
||||
is_default: bool
|
||||
|
||||
|
||||
class Charsets:
|
||||
"""MySQL supported character sets and collations class.
|
||||
|
||||
This class holds the list of character sets with their collations supported by
|
||||
MySQL, making available methods to get character sets by name, collation, or ID.
|
||||
It uses a sparse matrix or tree-like representation using a dict in a dict to hold
|
||||
the character set name and collations combinations.
|
||||
The list is hardcoded, so we avoid a database query when getting the name of the
|
||||
used character set or collation.
|
||||
|
||||
The call of ``charsets.set_mysql_major_version()`` should be done before using any
|
||||
of the retrieval methods.
|
||||
|
||||
Usage:
|
||||
>>> from mysql.connector.aio.charsets import charsets
|
||||
>>> charsets.set_mysql_major_version(8)
|
||||
>>> charsets.get_by_name("utf-8")
|
||||
Charset(charset_id=255,
|
||||
name='utf8mb4',
|
||||
collation='utf8mb4_0900_ai_ci',
|
||||
is_default=True)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._charset_id_store: Dict[int, Charset] = {}
|
||||
self._collation_store: Dict[str, Charset] = {}
|
||||
self._name_store: DefaultDict[str, Dict[str, Charset]] = defaultdict(dict)
|
||||
self._mysql_major_version: Optional[int] = None
|
||||
|
||||
def set_mysql_major_version(self, version: int) -> None:
|
||||
"""Set the MySQL major version.
|
||||
|
||||
Sets what tuple should be used based on the MySQL major version to store the
|
||||
list of character sets and collations.
|
||||
|
||||
Args:
|
||||
version: The MySQL major version (i.e. 8 or 5)
|
||||
"""
|
||||
self._mysql_major_version = version
|
||||
self._charset_id_store.clear()
|
||||
self._collation_store.clear()
|
||||
self._name_store.clear()
|
||||
|
||||
charsets_tuple: Sequence[Tuple[int, str, str, bool]] = None
|
||||
if version >= 8:
|
||||
charsets_tuple = MYSQL_8_CHARSETS
|
||||
elif version == 5:
|
||||
charsets_tuple = MYSQL_5_CHARSETS
|
||||
else:
|
||||
raise ProgrammingError("Invalid MySQL major version")
|
||||
|
||||
for charset_id, name, collation, is_default in charsets_tuple:
|
||||
charset = Charset(charset_id, name, collation, is_default)
|
||||
self._charset_id_store[charset_id] = charset
|
||||
self._collation_store[collation] = charset
|
||||
self._name_store[name][collation] = charset
|
||||
|
||||
def get_by_id(self, charset_id: int) -> Charset:
|
||||
"""Get character set by ID.
|
||||
|
||||
Args:
|
||||
charset_id: The charset ID.
|
||||
|
||||
Returns:
|
||||
Charset: The Charset dataclass instance.
|
||||
"""
|
||||
try:
|
||||
return self._charset_id_store[charset_id]
|
||||
except KeyError as err:
|
||||
raise ProgrammingError(f"Character set ID {charset_id} unknown") from err
|
||||
|
||||
def get_by_collation(self, collation: str) -> Charset:
|
||||
"""Get character set by collation.
|
||||
|
||||
Args:
|
||||
collation: The collation name.
|
||||
|
||||
Returns:
|
||||
Charset: The Charset dataclass instance.
|
||||
"""
|
||||
try:
|
||||
return self._collation_store[collation]
|
||||
except KeyError as err:
|
||||
raise ProgrammingError(f"Collation {collation} unknown") from err
|
||||
|
||||
def get_by_name(self, name: str) -> Charset:
|
||||
"""Get character set by name.
|
||||
|
||||
Args:
|
||||
name: The charset name.
|
||||
|
||||
Returns:
|
||||
Charset: The Charset dataclass instance.
|
||||
"""
|
||||
try:
|
||||
if name in ("utf8", "utf-8") and self._mysql_major_version == 8:
|
||||
name = "utf8mb4"
|
||||
for charset in self._name_store[name].values():
|
||||
if charset.is_default:
|
||||
return charset
|
||||
except KeyError as err:
|
||||
raise ProgrammingError(f"Character set name {name} unknown") from err
|
||||
raise ProgrammingError(f"No default was found for character set '{name}'")
|
||||
|
||||
def get_by_name_and_collation(self, name: str, collation: str) -> Charset:
|
||||
"""Get character set by name and collation.
|
||||
|
||||
Args:
|
||||
name: The charset name.
|
||||
collation: The collation name.
|
||||
|
||||
Returns:
|
||||
Charset: The Charset dataclass instance.
|
||||
"""
|
||||
try:
|
||||
return self._name_store[name][collation]
|
||||
except KeyError as err:
|
||||
raise ProgrammingError(
|
||||
f"Character set name '{name}' with collation '{collation}' not found"
|
||||
) from err
|
||||
|
||||
|
||||
MYSQL_8_CHARSETS = (
|
||||
(1, "big5", "big5_chinese_ci", True),
|
||||
(2, "latin2", "latin2_czech_cs", False),
|
||||
(3, "dec8", "dec8_swedish_ci", True),
|
||||
(4, "cp850", "cp850_general_ci", True),
|
||||
(5, "latin1", "latin1_german1_ci", False),
|
||||
(6, "hp8", "hp8_english_ci", True),
|
||||
(7, "koi8r", "koi8r_general_ci", True),
|
||||
(8, "latin1", "latin1_swedish_ci", True),
|
||||
(9, "latin2", "latin2_general_ci", True),
|
||||
(10, "swe7", "swe7_swedish_ci", True),
|
||||
(11, "ascii", "ascii_general_ci", True),
|
||||
(12, "ujis", "ujis_japanese_ci", True),
|
||||
(13, "sjis", "sjis_japanese_ci", True),
|
||||
(14, "cp1251", "cp1251_bulgarian_ci", False),
|
||||
(15, "latin1", "latin1_danish_ci", False),
|
||||
(16, "hebrew", "hebrew_general_ci", True),
|
||||
(18, "tis620", "tis620_thai_ci", True),
|
||||
(19, "euckr", "euckr_korean_ci", True),
|
||||
(20, "latin7", "latin7_estonian_cs", False),
|
||||
(21, "latin2", "latin2_hungarian_ci", False),
|
||||
(22, "koi8u", "koi8u_general_ci", True),
|
||||
(23, "cp1251", "cp1251_ukrainian_ci", False),
|
||||
(24, "gb2312", "gb2312_chinese_ci", True),
|
||||
(25, "greek", "greek_general_ci", True),
|
||||
(26, "cp1250", "cp1250_general_ci", True),
|
||||
(27, "latin2", "latin2_croatian_ci", False),
|
||||
(28, "gbk", "gbk_chinese_ci", True),
|
||||
(29, "cp1257", "cp1257_lithuanian_ci", False),
|
||||
(30, "latin5", "latin5_turkish_ci", True),
|
||||
(31, "latin1", "latin1_german2_ci", False),
|
||||
(32, "armscii8", "armscii8_general_ci", True),
|
||||
(33, "utf8mb3", "utf8mb3_general_ci", True),
|
||||
(34, "cp1250", "cp1250_czech_cs", False),
|
||||
(35, "ucs2", "ucs2_general_ci", True),
|
||||
(36, "cp866", "cp866_general_ci", True),
|
||||
(37, "keybcs2", "keybcs2_general_ci", True),
|
||||
(38, "macce", "macce_general_ci", True),
|
||||
(39, "macroman", "macroman_general_ci", True),
|
||||
(40, "cp852", "cp852_general_ci", True),
|
||||
(41, "latin7", "latin7_general_ci", True),
|
||||
(42, "latin7", "latin7_general_cs", False),
|
||||
(43, "macce", "macce_bin", False),
|
||||
(44, "cp1250", "cp1250_croatian_ci", False),
|
||||
(45, "utf8mb4", "utf8mb4_general_ci", False),
|
||||
(46, "utf8mb4", "utf8mb4_bin", False),
|
||||
(47, "latin1", "latin1_bin", False),
|
||||
(48, "latin1", "latin1_general_ci", False),
|
||||
(49, "latin1", "latin1_general_cs", False),
|
||||
(50, "cp1251", "cp1251_bin", False),
|
||||
(51, "cp1251", "cp1251_general_ci", True),
|
||||
(52, "cp1251", "cp1251_general_cs", False),
|
||||
(53, "macroman", "macroman_bin", False),
|
||||
(54, "utf16", "utf16_general_ci", True),
|
||||
(55, "utf16", "utf16_bin", False),
|
||||
(56, "utf16le", "utf16le_general_ci", True),
|
||||
(57, "cp1256", "cp1256_general_ci", True),
|
||||
(58, "cp1257", "cp1257_bin", False),
|
||||
(59, "cp1257", "cp1257_general_ci", True),
|
||||
(60, "utf32", "utf32_general_ci", True),
|
||||
(61, "utf32", "utf32_bin", False),
|
||||
(62, "utf16le", "utf16le_bin", False),
|
||||
(63, "binary", "binary", True),
|
||||
(64, "armscii8", "armscii8_bin", False),
|
||||
(65, "ascii", "ascii_bin", False),
|
||||
(66, "cp1250", "cp1250_bin", False),
|
||||
(67, "cp1256", "cp1256_bin", False),
|
||||
(68, "cp866", "cp866_bin", False),
|
||||
(69, "dec8", "dec8_bin", False),
|
||||
(70, "greek", "greek_bin", False),
|
||||
(71, "hebrew", "hebrew_bin", False),
|
||||
(72, "hp8", "hp8_bin", False),
|
||||
(73, "keybcs2", "keybcs2_bin", False),
|
||||
(74, "koi8r", "koi8r_bin", False),
|
||||
(75, "koi8u", "koi8u_bin", False),
|
||||
(76, "utf8mb3", "utf8mb3_tolower_ci", False),
|
||||
(77, "latin2", "latin2_bin", False),
|
||||
(78, "latin5", "latin5_bin", False),
|
||||
(79, "latin7", "latin7_bin", False),
|
||||
(80, "cp850", "cp850_bin", False),
|
||||
(81, "cp852", "cp852_bin", False),
|
||||
(82, "swe7", "swe7_bin", False),
|
||||
(83, "utf8mb3", "utf8mb3_bin", False),
|
||||
(84, "big5", "big5_bin", False),
|
||||
(85, "euckr", "euckr_bin", False),
|
||||
(86, "gb2312", "gb2312_bin", False),
|
||||
(87, "gbk", "gbk_bin", False),
|
||||
(88, "sjis", "sjis_bin", False),
|
||||
(89, "tis620", "tis620_bin", False),
|
||||
(90, "ucs2", "ucs2_bin", False),
|
||||
(91, "ujis", "ujis_bin", False),
|
||||
(92, "geostd8", "geostd8_general_ci", True),
|
||||
(93, "geostd8", "geostd8_bin", False),
|
||||
(94, "latin1", "latin1_spanish_ci", False),
|
||||
(95, "cp932", "cp932_japanese_ci", True),
|
||||
(96, "cp932", "cp932_bin", False),
|
||||
(97, "eucjpms", "eucjpms_japanese_ci", True),
|
||||
(98, "eucjpms", "eucjpms_bin", False),
|
||||
(99, "cp1250", "cp1250_polish_ci", False),
|
||||
(101, "utf16", "utf16_unicode_ci", False),
|
||||
(102, "utf16", "utf16_icelandic_ci", False),
|
||||
(103, "utf16", "utf16_latvian_ci", False),
|
||||
(104, "utf16", "utf16_romanian_ci", False),
|
||||
(105, "utf16", "utf16_slovenian_ci", False),
|
||||
(106, "utf16", "utf16_polish_ci", False),
|
||||
(107, "utf16", "utf16_estonian_ci", False),
|
||||
(108, "utf16", "utf16_spanish_ci", False),
|
||||
(109, "utf16", "utf16_swedish_ci", False),
|
||||
(110, "utf16", "utf16_turkish_ci", False),
|
||||
(111, "utf16", "utf16_czech_ci", False),
|
||||
(112, "utf16", "utf16_danish_ci", False),
|
||||
(113, "utf16", "utf16_lithuanian_ci", False),
|
||||
(114, "utf16", "utf16_slovak_ci", False),
|
||||
(115, "utf16", "utf16_spanish2_ci", False),
|
||||
(116, "utf16", "utf16_roman_ci", False),
|
||||
(117, "utf16", "utf16_persian_ci", False),
|
||||
(118, "utf16", "utf16_esperanto_ci", False),
|
||||
(119, "utf16", "utf16_hungarian_ci", False),
|
||||
(120, "utf16", "utf16_sinhala_ci", False),
|
||||
(121, "utf16", "utf16_german2_ci", False),
|
||||
(122, "utf16", "utf16_croatian_ci", False),
|
||||
(123, "utf16", "utf16_unicode_520_ci", False),
|
||||
(124, "utf16", "utf16_vietnamese_ci", False),
|
||||
(128, "ucs2", "ucs2_unicode_ci", False),
|
||||
(129, "ucs2", "ucs2_icelandic_ci", False),
|
||||
(130, "ucs2", "ucs2_latvian_ci", False),
|
||||
(131, "ucs2", "ucs2_romanian_ci", False),
|
||||
(132, "ucs2", "ucs2_slovenian_ci", False),
|
||||
(133, "ucs2", "ucs2_polish_ci", False),
|
||||
(134, "ucs2", "ucs2_estonian_ci", False),
|
||||
(135, "ucs2", "ucs2_spanish_ci", False),
|
||||
(136, "ucs2", "ucs2_swedish_ci", False),
|
||||
(137, "ucs2", "ucs2_turkish_ci", False),
|
||||
(138, "ucs2", "ucs2_czech_ci", False),
|
||||
(139, "ucs2", "ucs2_danish_ci", False),
|
||||
(140, "ucs2", "ucs2_lithuanian_ci", False),
|
||||
(141, "ucs2", "ucs2_slovak_ci", False),
|
||||
(142, "ucs2", "ucs2_spanish2_ci", False),
|
||||
(143, "ucs2", "ucs2_roman_ci", False),
|
||||
(144, "ucs2", "ucs2_persian_ci", False),
|
||||
(145, "ucs2", "ucs2_esperanto_ci", False),
|
||||
(146, "ucs2", "ucs2_hungarian_ci", False),
|
||||
(147, "ucs2", "ucs2_sinhala_ci", False),
|
||||
(148, "ucs2", "ucs2_german2_ci", False),
|
||||
(149, "ucs2", "ucs2_croatian_ci", False),
|
||||
(150, "ucs2", "ucs2_unicode_520_ci", False),
|
||||
(151, "ucs2", "ucs2_vietnamese_ci", False),
|
||||
(159, "ucs2", "ucs2_general_mysql500_ci", False),
|
||||
(160, "utf32", "utf32_unicode_ci", False),
|
||||
(161, "utf32", "utf32_icelandic_ci", False),
|
||||
(162, "utf32", "utf32_latvian_ci", False),
|
||||
(163, "utf32", "utf32_romanian_ci", False),
|
||||
(164, "utf32", "utf32_slovenian_ci", False),
|
||||
(165, "utf32", "utf32_polish_ci", False),
|
||||
(166, "utf32", "utf32_estonian_ci", False),
|
||||
(167, "utf32", "utf32_spanish_ci", False),
|
||||
(168, "utf32", "utf32_swedish_ci", False),
|
||||
(169, "utf32", "utf32_turkish_ci", False),
|
||||
(170, "utf32", "utf32_czech_ci", False),
|
||||
(171, "utf32", "utf32_danish_ci", False),
|
||||
(172, "utf32", "utf32_lithuanian_ci", False),
|
||||
(173, "utf32", "utf32_slovak_ci", False),
|
||||
(174, "utf32", "utf32_spanish2_ci", False),
|
||||
(175, "utf32", "utf32_roman_ci", False),
|
||||
(176, "utf32", "utf32_persian_ci", False),
|
||||
(177, "utf32", "utf32_esperanto_ci", False),
|
||||
(178, "utf32", "utf32_hungarian_ci", False),
|
||||
(179, "utf32", "utf32_sinhala_ci", False),
|
||||
(180, "utf32", "utf32_german2_ci", False),
|
||||
(181, "utf32", "utf32_croatian_ci", False),
|
||||
(182, "utf32", "utf32_unicode_520_ci", False),
|
||||
(183, "utf32", "utf32_vietnamese_ci", False),
|
||||
(192, "utf8mb3", "utf8mb3_unicode_ci", False),
|
||||
(193, "utf8mb3", "utf8mb3_icelandic_ci", False),
|
||||
(194, "utf8mb3", "utf8mb3_latvian_ci", False),
|
||||
(195, "utf8mb3", "utf8mb3_romanian_ci", False),
|
||||
(196, "utf8mb3", "utf8mb3_slovenian_ci", False),
|
||||
(197, "utf8mb3", "utf8mb3_polish_ci", False),
|
||||
(198, "utf8mb3", "utf8mb3_estonian_ci", False),
|
||||
(199, "utf8mb3", "utf8mb3_spanish_ci", False),
|
||||
(200, "utf8mb3", "utf8mb3_swedish_ci", False),
|
||||
(201, "utf8mb3", "utf8mb3_turkish_ci", False),
|
||||
(202, "utf8mb3", "utf8mb3_czech_ci", False),
|
||||
(203, "utf8mb3", "utf8mb3_danish_ci", False),
|
||||
(204, "utf8mb3", "utf8mb3_lithuanian_ci", False),
|
||||
(205, "utf8mb3", "utf8mb3_slovak_ci", False),
|
||||
(206, "utf8mb3", "utf8mb3_spanish2_ci", False),
|
||||
(207, "utf8mb3", "utf8mb3_roman_ci", False),
|
||||
(208, "utf8mb3", "utf8mb3_persian_ci", False),
|
||||
(209, "utf8mb3", "utf8mb3_esperanto_ci", False),
|
||||
(210, "utf8mb3", "utf8mb3_hungarian_ci", False),
|
||||
(211, "utf8mb3", "utf8mb3_sinhala_ci", False),
|
||||
(212, "utf8mb3", "utf8mb3_german2_ci", False),
|
||||
(213, "utf8mb3", "utf8mb3_croatian_ci", False),
|
||||
(214, "utf8mb3", "utf8mb3_unicode_520_ci", False),
|
||||
(215, "utf8mb3", "utf8mb3_vietnamese_ci", False),
|
||||
(223, "utf8mb3", "utf8mb3_general_mysql500_ci", False),
|
||||
(224, "utf8mb4", "utf8mb4_unicode_ci", False),
|
||||
(225, "utf8mb4", "utf8mb4_icelandic_ci", False),
|
||||
(226, "utf8mb4", "utf8mb4_latvian_ci", False),
|
||||
(227, "utf8mb4", "utf8mb4_romanian_ci", False),
|
||||
(228, "utf8mb4", "utf8mb4_slovenian_ci", False),
|
||||
(229, "utf8mb4", "utf8mb4_polish_ci", False),
|
||||
(230, "utf8mb4", "utf8mb4_estonian_ci", False),
|
||||
(231, "utf8mb4", "utf8mb4_spanish_ci", False),
|
||||
(232, "utf8mb4", "utf8mb4_swedish_ci", False),
|
||||
(233, "utf8mb4", "utf8mb4_turkish_ci", False),
|
||||
(234, "utf8mb4", "utf8mb4_czech_ci", False),
|
||||
(235, "utf8mb4", "utf8mb4_danish_ci", False),
|
||||
(236, "utf8mb4", "utf8mb4_lithuanian_ci", False),
|
||||
(237, "utf8mb4", "utf8mb4_slovak_ci", False),
|
||||
(238, "utf8mb4", "utf8mb4_spanish2_ci", False),
|
||||
(239, "utf8mb4", "utf8mb4_roman_ci", False),
|
||||
(240, "utf8mb4", "utf8mb4_persian_ci", False),
|
||||
(241, "utf8mb4", "utf8mb4_esperanto_ci", False),
|
||||
(242, "utf8mb4", "utf8mb4_hungarian_ci", False),
|
||||
(243, "utf8mb4", "utf8mb4_sinhala_ci", False),
|
||||
(244, "utf8mb4", "utf8mb4_german2_ci", False),
|
||||
(245, "utf8mb4", "utf8mb4_croatian_ci", False),
|
||||
(246, "utf8mb4", "utf8mb4_unicode_520_ci", False),
|
||||
(247, "utf8mb4", "utf8mb4_vietnamese_ci", False),
|
||||
(248, "gb18030", "gb18030_chinese_ci", True),
|
||||
(249, "gb18030", "gb18030_bin", False),
|
||||
(250, "gb18030", "gb18030_unicode_520_ci", False),
|
||||
(255, "utf8mb4", "utf8mb4_0900_ai_ci", True),
|
||||
(256, "utf8mb4", "utf8mb4_de_pb_0900_ai_ci", False),
|
||||
(257, "utf8mb4", "utf8mb4_is_0900_ai_ci", False),
|
||||
(258, "utf8mb4", "utf8mb4_lv_0900_ai_ci", False),
|
||||
(259, "utf8mb4", "utf8mb4_ro_0900_ai_ci", False),
|
||||
(260, "utf8mb4", "utf8mb4_sl_0900_ai_ci", False),
|
||||
(261, "utf8mb4", "utf8mb4_pl_0900_ai_ci", False),
|
||||
(262, "utf8mb4", "utf8mb4_et_0900_ai_ci", False),
|
||||
(263, "utf8mb4", "utf8mb4_es_0900_ai_ci", False),
|
||||
(264, "utf8mb4", "utf8mb4_sv_0900_ai_ci", False),
|
||||
(265, "utf8mb4", "utf8mb4_tr_0900_ai_ci", False),
|
||||
(266, "utf8mb4", "utf8mb4_cs_0900_ai_ci", False),
|
||||
(267, "utf8mb4", "utf8mb4_da_0900_ai_ci", False),
|
||||
(268, "utf8mb4", "utf8mb4_lt_0900_ai_ci", False),
|
||||
(269, "utf8mb4", "utf8mb4_sk_0900_ai_ci", False),
|
||||
(270, "utf8mb4", "utf8mb4_es_trad_0900_ai_ci", False),
|
||||
(271, "utf8mb4", "utf8mb4_la_0900_ai_ci", False),
|
||||
(273, "utf8mb4", "utf8mb4_eo_0900_ai_ci", False),
|
||||
(274, "utf8mb4", "utf8mb4_hu_0900_ai_ci", False),
|
||||
(275, "utf8mb4", "utf8mb4_hr_0900_ai_ci", False),
|
||||
(277, "utf8mb4", "utf8mb4_vi_0900_ai_ci", False),
|
||||
(278, "utf8mb4", "utf8mb4_0900_as_cs", False),
|
||||
(279, "utf8mb4", "utf8mb4_de_pb_0900_as_cs", False),
|
||||
(280, "utf8mb4", "utf8mb4_is_0900_as_cs", False),
|
||||
(281, "utf8mb4", "utf8mb4_lv_0900_as_cs", False),
|
||||
(282, "utf8mb4", "utf8mb4_ro_0900_as_cs", False),
|
||||
(283, "utf8mb4", "utf8mb4_sl_0900_as_cs", False),
|
||||
(284, "utf8mb4", "utf8mb4_pl_0900_as_cs", False),
|
||||
(285, "utf8mb4", "utf8mb4_et_0900_as_cs", False),
|
||||
(286, "utf8mb4", "utf8mb4_es_0900_as_cs", False),
|
||||
(287, "utf8mb4", "utf8mb4_sv_0900_as_cs", False),
|
||||
(288, "utf8mb4", "utf8mb4_tr_0900_as_cs", False),
|
||||
(289, "utf8mb4", "utf8mb4_cs_0900_as_cs", False),
|
||||
(290, "utf8mb4", "utf8mb4_da_0900_as_cs", False),
|
||||
(291, "utf8mb4", "utf8mb4_lt_0900_as_cs", False),
|
||||
(292, "utf8mb4", "utf8mb4_sk_0900_as_cs", False),
|
||||
(293, "utf8mb4", "utf8mb4_es_trad_0900_as_cs", False),
|
||||
(294, "utf8mb4", "utf8mb4_la_0900_as_cs", False),
|
||||
(296, "utf8mb4", "utf8mb4_eo_0900_as_cs", False),
|
||||
(297, "utf8mb4", "utf8mb4_hu_0900_as_cs", False),
|
||||
(298, "utf8mb4", "utf8mb4_hr_0900_as_cs", False),
|
||||
(300, "utf8mb4", "utf8mb4_vi_0900_as_cs", False),
|
||||
(303, "utf8mb4", "utf8mb4_ja_0900_as_cs", False),
|
||||
(304, "utf8mb4", "utf8mb4_ja_0900_as_cs_ks", False),
|
||||
(305, "utf8mb4", "utf8mb4_0900_as_ci", False),
|
||||
(306, "utf8mb4", "utf8mb4_ru_0900_ai_ci", False),
|
||||
(307, "utf8mb4", "utf8mb4_ru_0900_as_cs", False),
|
||||
(308, "utf8mb4", "utf8mb4_zh_0900_as_cs", False),
|
||||
(309, "utf8mb4", "utf8mb4_0900_bin", False),
|
||||
(310, "utf8mb4", "utf8mb4_nb_0900_ai_ci", False),
|
||||
(311, "utf8mb4", "utf8mb4_nb_0900_as_cs", False),
|
||||
(312, "utf8mb4", "utf8mb4_nn_0900_ai_ci", False),
|
||||
(313, "utf8mb4", "utf8mb4_nn_0900_as_cs", False),
|
||||
(314, "utf8mb4", "utf8mb4_sr_latn_0900_ai_ci", False),
|
||||
(315, "utf8mb4", "utf8mb4_sr_latn_0900_as_cs", False),
|
||||
(316, "utf8mb4", "utf8mb4_bs_0900_ai_ci", False),
|
||||
(317, "utf8mb4", "utf8mb4_bs_0900_as_cs", False),
|
||||
(318, "utf8mb4", "utf8mb4_bg_0900_ai_ci", False),
|
||||
(319, "utf8mb4", "utf8mb4_bg_0900_as_cs", False),
|
||||
(320, "utf8mb4", "utf8mb4_gl_0900_ai_ci", False),
|
||||
(321, "utf8mb4", "utf8mb4_gl_0900_as_cs", False),
|
||||
(322, "utf8mb4", "utf8mb4_mn_cyrl_0900_ai_ci", False),
|
||||
(323, "utf8mb4", "utf8mb4_mn_cyrl_0900_as_cs", False),
|
||||
)
|
||||
|
||||
MYSQL_5_CHARSETS = (
|
||||
(1, "big5", "big5_chinese_ci", True),
|
||||
(2, "latin2", "latin2_czech_cs", False),
|
||||
(3, "dec8", "dec8_swedish_ci", True),
|
||||
(4, "cp850", "cp850_general_ci", True),
|
||||
(5, "latin1", "latin1_german1_ci", False),
|
||||
(6, "hp8", "hp8_english_ci", True),
|
||||
(7, "koi8r", "koi8r_general_ci", True),
|
||||
(8, "latin1", "latin1_swedish_ci", True),
|
||||
(9, "latin2", "latin2_general_ci", True),
|
||||
(10, "swe7", "swe7_swedish_ci", True),
|
||||
(11, "ascii", "ascii_general_ci", True),
|
||||
(12, "ujis", "ujis_japanese_ci", True),
|
||||
(13, "sjis", "sjis_japanese_ci", True),
|
||||
(14, "cp1251", "cp1251_bulgarian_ci", False),
|
||||
(15, "latin1", "latin1_danish_ci", False),
|
||||
(16, "hebrew", "hebrew_general_ci", True),
|
||||
(18, "tis620", "tis620_thai_ci", True),
|
||||
(19, "euckr", "euckr_korean_ci", True),
|
||||
(20, "latin7", "latin7_estonian_cs", False),
|
||||
(21, "latin2", "latin2_hungarian_ci", False),
|
||||
(22, "koi8u", "koi8u_general_ci", True),
|
||||
(23, "cp1251", "cp1251_ukrainian_ci", False),
|
||||
(24, "gb2312", "gb2312_chinese_ci", True),
|
||||
(25, "greek", "greek_general_ci", True),
|
||||
(26, "cp1250", "cp1250_general_ci", True),
|
||||
(27, "latin2", "latin2_croatian_ci", False),
|
||||
(28, "gbk", "gbk_chinese_ci", True),
|
||||
(29, "cp1257", "cp1257_lithuanian_ci", False),
|
||||
(30, "latin5", "latin5_turkish_ci", True),
|
||||
(31, "latin1", "latin1_german2_ci", False),
|
||||
(32, "armscii8", "armscii8_general_ci", True),
|
||||
(33, "utf8", "utf8_general_ci", True),
|
||||
(34, "cp1250", "cp1250_czech_cs", False),
|
||||
(35, "ucs2", "ucs2_general_ci", True),
|
||||
(36, "cp866", "cp866_general_ci", True),
|
||||
(37, "keybcs2", "keybcs2_general_ci", True),
|
||||
(38, "macce", "macce_general_ci", True),
|
||||
(39, "macroman", "macroman_general_ci", True),
|
||||
(40, "cp852", "cp852_general_ci", True),
|
||||
(41, "latin7", "latin7_general_ci", True),
|
||||
(42, "latin7", "latin7_general_cs", False),
|
||||
(43, "macce", "macce_bin", False),
|
||||
(44, "cp1250", "cp1250_croatian_ci", False),
|
||||
(45, "utf8mb4", "utf8mb4_general_ci", True),
|
||||
(46, "utf8mb4", "utf8mb4_bin", False),
|
||||
(47, "latin1", "latin1_bin", False),
|
||||
(48, "latin1", "latin1_general_ci", False),
|
||||
(49, "latin1", "latin1_general_cs", False),
|
||||
(50, "cp1251", "cp1251_bin", False),
|
||||
(51, "cp1251", "cp1251_general_ci", True),
|
||||
(52, "cp1251", "cp1251_general_cs", False),
|
||||
(53, "macroman", "macroman_bin", False),
|
||||
(54, "utf16", "utf16_general_ci", True),
|
||||
(55, "utf16", "utf16_bin", False),
|
||||
(56, "utf16le", "utf16le_general_ci", True),
|
||||
(57, "cp1256", "cp1256_general_ci", True),
|
||||
(58, "cp1257", "cp1257_bin", False),
|
||||
(59, "cp1257", "cp1257_general_ci", True),
|
||||
(60, "utf32", "utf32_general_ci", True),
|
||||
(61, "utf32", "utf32_bin", False),
|
||||
(62, "utf16le", "utf16le_bin", False),
|
||||
(63, "binary", "binary", True),
|
||||
(64, "armscii8", "armscii8_bin", False),
|
||||
(65, "ascii", "ascii_bin", False),
|
||||
(66, "cp1250", "cp1250_bin", False),
|
||||
(67, "cp1256", "cp1256_bin", False),
|
||||
(68, "cp866", "cp866_bin", False),
|
||||
(69, "dec8", "dec8_bin", False),
|
||||
(70, "greek", "greek_bin", False),
|
||||
(71, "hebrew", "hebrew_bin", False),
|
||||
(72, "hp8", "hp8_bin", False),
|
||||
(73, "keybcs2", "keybcs2_bin", False),
|
||||
(74, "koi8r", "koi8r_bin", False),
|
||||
(75, "koi8u", "koi8u_bin", False),
|
||||
(77, "latin2", "latin2_bin", False),
|
||||
(78, "latin5", "latin5_bin", False),
|
||||
(79, "latin7", "latin7_bin", False),
|
||||
(80, "cp850", "cp850_bin", False),
|
||||
(81, "cp852", "cp852_bin", False),
|
||||
(82, "swe7", "swe7_bin", False),
|
||||
(83, "utf8", "utf8_bin", False),
|
||||
(84, "big5", "big5_bin", False),
|
||||
(85, "euckr", "euckr_bin", False),
|
||||
(86, "gb2312", "gb2312_bin", False),
|
||||
(87, "gbk", "gbk_bin", False),
|
||||
(88, "sjis", "sjis_bin", False),
|
||||
(89, "tis620", "tis620_bin", False),
|
||||
(90, "ucs2", "ucs2_bin", False),
|
||||
(91, "ujis", "ujis_bin", False),
|
||||
(92, "geostd8", "geostd8_general_ci", True),
|
||||
(93, "geostd8", "geostd8_bin", False),
|
||||
(94, "latin1", "latin1_spanish_ci", False),
|
||||
(95, "cp932", "cp932_japanese_ci", True),
|
||||
(96, "cp932", "cp932_bin", False),
|
||||
(97, "eucjpms", "eucjpms_japanese_ci", True),
|
||||
(98, "eucjpms", "eucjpms_bin", False),
|
||||
(99, "cp1250", "cp1250_polish_ci", False),
|
||||
(101, "utf16", "utf16_unicode_ci", False),
|
||||
(102, "utf16", "utf16_icelandic_ci", False),
|
||||
(103, "utf16", "utf16_latvian_ci", False),
|
||||
(104, "utf16", "utf16_romanian_ci", False),
|
||||
(105, "utf16", "utf16_slovenian_ci", False),
|
||||
(106, "utf16", "utf16_polish_ci", False),
|
||||
(107, "utf16", "utf16_estonian_ci", False),
|
||||
(108, "utf16", "utf16_spanish_ci", False),
|
||||
(109, "utf16", "utf16_swedish_ci", False),
|
||||
(110, "utf16", "utf16_turkish_ci", False),
|
||||
(111, "utf16", "utf16_czech_ci", False),
|
||||
(112, "utf16", "utf16_danish_ci", False),
|
||||
(113, "utf16", "utf16_lithuanian_ci", False),
|
||||
(114, "utf16", "utf16_slovak_ci", False),
|
||||
(115, "utf16", "utf16_spanish2_ci", False),
|
||||
(116, "utf16", "utf16_roman_ci", False),
|
||||
(117, "utf16", "utf16_persian_ci", False),
|
||||
(118, "utf16", "utf16_esperanto_ci", False),
|
||||
(119, "utf16", "utf16_hungarian_ci", False),
|
||||
(120, "utf16", "utf16_sinhala_ci", False),
|
||||
(121, "utf16", "utf16_german2_ci", False),
|
||||
(122, "utf16", "utf16_croatian_ci", False),
|
||||
(123, "utf16", "utf16_unicode_520_ci", False),
|
||||
(124, "utf16", "utf16_vietnamese_ci", False),
|
||||
(128, "ucs2", "ucs2_unicode_ci", False),
|
||||
(129, "ucs2", "ucs2_icelandic_ci", False),
|
||||
(130, "ucs2", "ucs2_latvian_ci", False),
|
||||
(131, "ucs2", "ucs2_romanian_ci", False),
|
||||
(132, "ucs2", "ucs2_slovenian_ci", False),
|
||||
(133, "ucs2", "ucs2_polish_ci", False),
|
||||
(134, "ucs2", "ucs2_estonian_ci", False),
|
||||
(135, "ucs2", "ucs2_spanish_ci", False),
|
||||
(136, "ucs2", "ucs2_swedish_ci", False),
|
||||
(137, "ucs2", "ucs2_turkish_ci", False),
|
||||
(138, "ucs2", "ucs2_czech_ci", False),
|
||||
(139, "ucs2", "ucs2_danish_ci", False),
|
||||
(140, "ucs2", "ucs2_lithuanian_ci", False),
|
||||
(141, "ucs2", "ucs2_slovak_ci", False),
|
||||
(142, "ucs2", "ucs2_spanish2_ci", False),
|
||||
(143, "ucs2", "ucs2_roman_ci", False),
|
||||
(144, "ucs2", "ucs2_persian_ci", False),
|
||||
(145, "ucs2", "ucs2_esperanto_ci", False),
|
||||
(146, "ucs2", "ucs2_hungarian_ci", False),
|
||||
(147, "ucs2", "ucs2_sinhala_ci", False),
|
||||
(148, "ucs2", "ucs2_german2_ci", False),
|
||||
(149, "ucs2", "ucs2_croatian_ci", False),
|
||||
(150, "ucs2", "ucs2_unicode_520_ci", False),
|
||||
(151, "ucs2", "ucs2_vietnamese_ci", False),
|
||||
(159, "ucs2", "ucs2_general_mysql500_ci", False),
|
||||
(160, "utf32", "utf32_unicode_ci", False),
|
||||
(161, "utf32", "utf32_icelandic_ci", False),
|
||||
(162, "utf32", "utf32_latvian_ci", False),
|
||||
(163, "utf32", "utf32_romanian_ci", False),
|
||||
(164, "utf32", "utf32_slovenian_ci", False),
|
||||
(165, "utf32", "utf32_polish_ci", False),
|
||||
(166, "utf32", "utf32_estonian_ci", False),
|
||||
(167, "utf32", "utf32_spanish_ci", False),
|
||||
(168, "utf32", "utf32_swedish_ci", False),
|
||||
(169, "utf32", "utf32_turkish_ci", False),
|
||||
(170, "utf32", "utf32_czech_ci", False),
|
||||
(171, "utf32", "utf32_danish_ci", False),
|
||||
(172, "utf32", "utf32_lithuanian_ci", False),
|
||||
(173, "utf32", "utf32_slovak_ci", False),
|
||||
(174, "utf32", "utf32_spanish2_ci", False),
|
||||
(175, "utf32", "utf32_roman_ci", False),
|
||||
(176, "utf32", "utf32_persian_ci", False),
|
||||
(177, "utf32", "utf32_esperanto_ci", False),
|
||||
(178, "utf32", "utf32_hungarian_ci", False),
|
||||
(179, "utf32", "utf32_sinhala_ci", False),
|
||||
(180, "utf32", "utf32_german2_ci", False),
|
||||
(181, "utf32", "utf32_croatian_ci", False),
|
||||
(182, "utf32", "utf32_unicode_520_ci", False),
|
||||
(183, "utf32", "utf32_vietnamese_ci", False),
|
||||
(192, "utf8", "utf8_unicode_ci", False),
|
||||
(193, "utf8", "utf8_icelandic_ci", False),
|
||||
(194, "utf8", "utf8_latvian_ci", False),
|
||||
(195, "utf8", "utf8_romanian_ci", False),
|
||||
(196, "utf8", "utf8_slovenian_ci", False),
|
||||
(197, "utf8", "utf8_polish_ci", False),
|
||||
(198, "utf8", "utf8_estonian_ci", False),
|
||||
(199, "utf8", "utf8_spanish_ci", False),
|
||||
(200, "utf8", "utf8_swedish_ci", False),
|
||||
(201, "utf8", "utf8_turkish_ci", False),
|
||||
(202, "utf8", "utf8_czech_ci", False),
|
||||
(203, "utf8", "utf8_danish_ci", False),
|
||||
(204, "utf8", "utf8_lithuanian_ci", False),
|
||||
(205, "utf8", "utf8_slovak_ci", False),
|
||||
(206, "utf8", "utf8_spanish2_ci", False),
|
||||
(207, "utf8", "utf8_roman_ci", False),
|
||||
(208, "utf8", "utf8_persian_ci", False),
|
||||
(209, "utf8", "utf8_esperanto_ci", False),
|
||||
(210, "utf8", "utf8_hungarian_ci", False),
|
||||
(211, "utf8", "utf8_sinhala_ci", False),
|
||||
(212, "utf8", "utf8_german2_ci", False),
|
||||
(213, "utf8", "utf8_croatian_ci", False),
|
||||
(214, "utf8", "utf8_unicode_520_ci", False),
|
||||
(215, "utf8", "utf8_vietnamese_ci", False),
|
||||
(223, "utf8", "utf8_general_mysql500_ci", False),
|
||||
(224, "utf8mb4", "utf8mb4_unicode_ci", False),
|
||||
(225, "utf8mb4", "utf8mb4_icelandic_ci", False),
|
||||
(226, "utf8mb4", "utf8mb4_latvian_ci", False),
|
||||
(227, "utf8mb4", "utf8mb4_romanian_ci", False),
|
||||
(228, "utf8mb4", "utf8mb4_slovenian_ci", False),
|
||||
(229, "utf8mb4", "utf8mb4_polish_ci", False),
|
||||
(230, "utf8mb4", "utf8mb4_estonian_ci", False),
|
||||
(231, "utf8mb4", "utf8mb4_spanish_ci", False),
|
||||
(232, "utf8mb4", "utf8mb4_swedish_ci", False),
|
||||
(233, "utf8mb4", "utf8mb4_turkish_ci", False),
|
||||
(234, "utf8mb4", "utf8mb4_czech_ci", False),
|
||||
(235, "utf8mb4", "utf8mb4_danish_ci", False),
|
||||
(236, "utf8mb4", "utf8mb4_lithuanian_ci", False),
|
||||
(237, "utf8mb4", "utf8mb4_slovak_ci", False),
|
||||
(238, "utf8mb4", "utf8mb4_spanish2_ci", False),
|
||||
(239, "utf8mb4", "utf8mb4_roman_ci", False),
|
||||
(240, "utf8mb4", "utf8mb4_persian_ci", False),
|
||||
(241, "utf8mb4", "utf8mb4_esperanto_ci", False),
|
||||
(242, "utf8mb4", "utf8mb4_hungarian_ci", False),
|
||||
(243, "utf8mb4", "utf8mb4_sinhala_ci", False),
|
||||
(244, "utf8mb4", "utf8mb4_german2_ci", False),
|
||||
(245, "utf8mb4", "utf8mb4_croatian_ci", False),
|
||||
(246, "utf8mb4", "utf8mb4_unicode_520_ci", False),
|
||||
(247, "utf8mb4", "utf8mb4_vietnamese_ci", False),
|
||||
(248, "gb18030", "gb18030_chinese_ci", True),
|
||||
(249, "gb18030", "gb18030_bin", False),
|
||||
(250, "gb18030", "gb18030_unicode_520_ci", False),
|
||||
)
|
||||
|
||||
charsets = Charsets()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Setup of the `mysql.connector.aio` logger."""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("mysql.connector.aio")
|
||||
@@ -0,0 +1,765 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# pylint: disable=dangerous-default-value
|
||||
|
||||
"""Module implementing low-level socket communication with MySQL servers."""
|
||||
|
||||
|
||||
__all__ = ["MySQLTcpSocket", "MySQLUnixSocket"]
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
try:
|
||||
import ssl
|
||||
|
||||
TLS_VERSIONS = {
|
||||
"TLSv1": ssl.PROTOCOL_TLSv1,
|
||||
"TLSv1.1": ssl.PROTOCOL_TLSv1_1,
|
||||
"TLSv1.2": ssl.PROTOCOL_TLSv1_2,
|
||||
"TLSv1.3": ssl.PROTOCOL_TLS,
|
||||
}
|
||||
except ImportError:
|
||||
ssl = None
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import deque
|
||||
from typing import Any, Deque, List, Optional, Tuple
|
||||
|
||||
from ..errors import (
|
||||
InterfaceError,
|
||||
NotSupportedError,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
ReadTimeoutError,
|
||||
WriteTimeoutError,
|
||||
)
|
||||
from ..network import (
|
||||
COMPRESSED_PACKET_HEADER_LENGTH,
|
||||
MAX_PAYLOAD_LENGTH,
|
||||
MIN_COMPRESS_LENGTH,
|
||||
PACKET_HEADER_LENGTH,
|
||||
)
|
||||
from .utils import StreamWriter, open_connection
|
||||
|
||||
|
||||
def _strioerror(err: IOError) -> str:
|
||||
"""Reformat the IOError error message.
|
||||
|
||||
This function reformats the IOError error message.
|
||||
"""
|
||||
return str(err) if not err.errno else f"{err.errno} {err.strerror}"
|
||||
|
||||
|
||||
class NetworkBroker(ABC):
|
||||
"""Broker class interface.
|
||||
|
||||
The network object is a broker used as a delegate by a socket object. Whenever the
|
||||
socket wants to deliver or get packets to or from the MySQL server it needs to rely
|
||||
on its network broker (netbroker).
|
||||
|
||||
The netbroker sends `payloads` and receives `packets`.
|
||||
|
||||
A packet is a bytes sequence, it has a header and body (referred to as payload).
|
||||
The first `PACKET_HEADER_LENGTH` or `COMPRESSED_PACKET_HEADER_LENGTH`
|
||||
(as appropriate) bytes correspond to the `header`, the remaining ones represent the
|
||||
`payload`.
|
||||
|
||||
The maximum payload length allowed to be sent per packet to the server is
|
||||
`MAX_PAYLOAD_LENGTH`. When `send` is called with a payload whose length is greater
|
||||
than `MAX_PAYLOAD_LENGTH` the netbroker breaks it down into packets, so the caller
|
||||
of `send` can provide payloads of arbitrary length.
|
||||
|
||||
Finally, data received by the netbroker comes directly from the server, expect to
|
||||
get a packet for each call to `recv`. The received packet contains a header and
|
||||
payload, the latter respecting `MAX_PAYLOAD_LENGTH`.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def write(
|
||||
self,
|
||||
writer: StreamWriter,
|
||||
address: str,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send `payload` to the MySQL server.
|
||||
|
||||
If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is
|
||||
broken down into packets.
|
||||
|
||||
Args:
|
||||
sock: Object holding the socket connection.
|
||||
address: Socket's location.
|
||||
payload: Packet's body to send.
|
||||
packet_number: Sequence id (packet ID) to attach to the header when sending
|
||||
plain packets.
|
||||
compressed_packet_number: Same as `packet_number` but used when sending
|
||||
compressed packets.
|
||||
write_timeout: Timeout in seconds before which sending a packet to the server
|
||||
should finish else WriteTimeoutError is raised.
|
||||
|
||||
|
||||
Raises:
|
||||
:class:`OperationalError`: If something goes wrong while sending packets to
|
||||
the MySQL server.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def read(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
address: str,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> bytearray:
|
||||
"""Get the next available packet from the MySQL server.
|
||||
|
||||
Args:
|
||||
sock: Object holding the socket connection.
|
||||
address: Socket's location.
|
||||
read_timeout: Timeout in seconds before which reading a packet from the server
|
||||
should finish.
|
||||
|
||||
Returns:
|
||||
packet: A packet from the MySQL server.
|
||||
|
||||
Raises:
|
||||
:class:`OperationalError`: If something goes wrong while receiving packets
|
||||
from the MySQL server.
|
||||
:class:`ReadTimeoutError`: If the time to receive a packet from the server takes
|
||||
longer than `read_timeout`.
|
||||
:class:`InterfaceError`: If something goes wrong while receiving packets
|
||||
from the MySQL server.
|
||||
"""
|
||||
|
||||
|
||||
class NetworkBrokerPlain(NetworkBroker):
|
||||
"""Broker class for MySQL socket communication."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pktnr: int = -1 # packet number
|
||||
|
||||
@staticmethod
|
||||
def get_header(pkt: bytes) -> Tuple[int, int]:
|
||||
"""Recover the header information from a packet."""
|
||||
if len(pkt) < PACKET_HEADER_LENGTH:
|
||||
raise ValueError("Can't recover header info from an incomplete packet")
|
||||
|
||||
pll, seqid = (
|
||||
struct.unpack("<I", pkt[0:3] + b"\x00")[0],
|
||||
pkt[3],
|
||||
)
|
||||
# payload length, sequence id
|
||||
return pll, seqid
|
||||
|
||||
def _set_next_pktnr(self, next_id: Optional[int] = None) -> None:
|
||||
"""Set the given packet id, if any, else increment packet id."""
|
||||
if next_id is None:
|
||||
self._pktnr += 1
|
||||
else:
|
||||
self._pktnr = next_id
|
||||
self._pktnr %= 256
|
||||
|
||||
async def _write_pkt(
|
||||
self,
|
||||
writer: StreamWriter,
|
||||
address: str,
|
||||
pkt: bytes,
|
||||
) -> None:
|
||||
"""Write packet to the comm channel."""
|
||||
try:
|
||||
writer.write(pkt)
|
||||
await writer.drain()
|
||||
except IOError as err:
|
||||
raise OperationalError(
|
||||
errno=2055, values=(address, _strioerror(err))
|
||||
) from err
|
||||
except AttributeError as err:
|
||||
raise OperationalError(errno=2006) from err
|
||||
|
||||
async def _read_chunk(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
size: int = 0,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> bytearray:
|
||||
"""Read `size` bytes from the comm channel."""
|
||||
try:
|
||||
pkt = bytearray(b"")
|
||||
while len(pkt) < size:
|
||||
chunk = await asyncio.wait_for(
|
||||
reader.read(size - len(pkt)), read_timeout
|
||||
)
|
||||
if not chunk:
|
||||
raise InterfaceError(errno=2013)
|
||||
pkt += chunk
|
||||
return pkt
|
||||
except asyncio.TimeoutError as err:
|
||||
raise ReadTimeoutError(errno=3024) from err
|
||||
except asyncio.CancelledError as err:
|
||||
raise err
|
||||
|
||||
async def write(
|
||||
self,
|
||||
writer: StreamWriter,
|
||||
address: str,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send payload to the MySQL server.
|
||||
|
||||
If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is
|
||||
broken down into packets.
|
||||
"""
|
||||
self._set_next_pktnr(packet_number)
|
||||
# If the payload is larger than or equal to MAX_PAYLOAD_LENGTH the length is
|
||||
# set to 2^24 - 1 (ff ff ff) and additional packets are sent with the rest of
|
||||
# the payload until the payload of a packet is less than MAX_PAYLOAD_LENGTH.
|
||||
offset = 0
|
||||
try:
|
||||
for _ in range(len(payload) // MAX_PAYLOAD_LENGTH):
|
||||
# payload_len, sequence_id, payload
|
||||
await asyncio.wait_for(
|
||||
self._write_pkt(
|
||||
writer,
|
||||
address,
|
||||
b"\xff" * 3
|
||||
+ struct.pack("<B", self._pktnr)
|
||||
+ payload[offset : offset + MAX_PAYLOAD_LENGTH],
|
||||
),
|
||||
write_timeout,
|
||||
)
|
||||
self._set_next_pktnr()
|
||||
offset += MAX_PAYLOAD_LENGTH
|
||||
await asyncio.wait_for(
|
||||
self._write_pkt(
|
||||
writer,
|
||||
address,
|
||||
struct.pack("<I", len(payload) - offset)[0:3]
|
||||
+ struct.pack("<B", self._pktnr)
|
||||
+ payload[offset:],
|
||||
),
|
||||
write_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError as err:
|
||||
raise WriteTimeoutError(errno=3024) from err
|
||||
except asyncio.CancelledError as err:
|
||||
raise err
|
||||
|
||||
async def read(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
address: str,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> bytearray:
|
||||
"""Receive `one` packet from the MySQL server."""
|
||||
try:
|
||||
# Read the header of the MySQL packet.
|
||||
header = await self._read_chunk(reader, PACKET_HEADER_LENGTH, read_timeout)
|
||||
|
||||
# Pull the payload length and sequence id.
|
||||
payload_len, self._pktnr = self.get_header(header)
|
||||
|
||||
# Read the payload, and return packet.
|
||||
return header + await self._read_chunk(reader, payload_len, read_timeout)
|
||||
except IOError as err:
|
||||
raise OperationalError(
|
||||
errno=2055, values=(address, _strioerror(err))
|
||||
) from err
|
||||
|
||||
|
||||
class NetworkBrokerCompressed(NetworkBrokerPlain):
|
||||
"""Broker class for MySQL socket communication."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._compressed_pktnr = -1
|
||||
self._queue_read: Deque[bytearray] = deque()
|
||||
|
||||
@staticmethod
|
||||
def _prepare_packets(payload: bytes, pktnr: int) -> List[bytes]:
|
||||
"""Prepare a payload for sending to the MySQL server."""
|
||||
offset = 0
|
||||
pkts = []
|
||||
|
||||
# If the payload is larger than or equal to MAX_PAYLOAD_LENGTH the length is
|
||||
# set to 2^24 - 1 (ff ff ff) and additional packets are sent with the rest of
|
||||
# the payload until the payload of a packet is less than MAX_PAYLOAD_LENGTH.
|
||||
for _ in range(len(payload) // MAX_PAYLOAD_LENGTH):
|
||||
# payload length + sequence id + payload
|
||||
pkts.append(
|
||||
b"\xff" * 3
|
||||
+ struct.pack("<B", pktnr)
|
||||
+ payload[offset : offset + MAX_PAYLOAD_LENGTH]
|
||||
)
|
||||
pktnr = (pktnr + 1) % 256
|
||||
offset += MAX_PAYLOAD_LENGTH
|
||||
pkts.append(
|
||||
struct.pack("<I", len(payload) - offset)[0:3]
|
||||
+ struct.pack("<B", pktnr)
|
||||
+ payload[offset:]
|
||||
)
|
||||
return pkts
|
||||
|
||||
@staticmethod
|
||||
def get_header(pkt: bytes) -> Tuple[int, int, int]: # type: ignore[override]
|
||||
"""Recover the header information from a packet."""
|
||||
if len(pkt) < COMPRESSED_PACKET_HEADER_LENGTH:
|
||||
raise ValueError("Can't recover header info from an incomplete packet")
|
||||
|
||||
compressed_pll, seqid, uncompressed_pll = (
|
||||
struct.unpack("<I", pkt[0:3] + b"\x00")[0],
|
||||
pkt[3],
|
||||
struct.unpack("<I", pkt[4:7] + b"\x00")[0],
|
||||
)
|
||||
# compressed payload length, sequence id, uncompressed payload length
|
||||
return compressed_pll, seqid, uncompressed_pll
|
||||
|
||||
def _set_next_compressed_pktnr(self, next_id: Optional[int] = None) -> None:
|
||||
"""Set the given packet id, if any, else increment packet id."""
|
||||
if next_id is None:
|
||||
self._compressed_pktnr += 1
|
||||
else:
|
||||
self._compressed_pktnr = next_id
|
||||
self._compressed_pktnr %= 256
|
||||
|
||||
async def _write_pkt(
|
||||
self,
|
||||
writer: StreamWriter,
|
||||
address: str,
|
||||
pkt: bytes,
|
||||
) -> None:
|
||||
"""Compress packet and write it to the comm channel."""
|
||||
compressed_pkt = zlib.compress(pkt)
|
||||
pkt = (
|
||||
struct.pack("<I", len(compressed_pkt))[0:3]
|
||||
+ struct.pack("<B", self._compressed_pktnr)
|
||||
+ struct.pack("<I", len(pkt))[0:3]
|
||||
+ compressed_pkt
|
||||
)
|
||||
return await super()._write_pkt(writer, address, pkt)
|
||||
|
||||
async def write(
|
||||
self,
|
||||
writer: StreamWriter,
|
||||
address: str,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send `payload` as compressed packets to the MySQL server.
|
||||
|
||||
If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is
|
||||
broken down into packets.
|
||||
"""
|
||||
# Get next packet numbers.
|
||||
self._set_next_pktnr(packet_number)
|
||||
self._set_next_compressed_pktnr(compressed_packet_number)
|
||||
try:
|
||||
payload_prep = bytearray(b"").join(
|
||||
self._prepare_packets(payload, self._pktnr)
|
||||
)
|
||||
if len(payload) >= MAX_PAYLOAD_LENGTH - PACKET_HEADER_LENGTH:
|
||||
# Sending a MySQL payload of the size greater or equal to 2^24 - 5 via
|
||||
# compression leads to at least one extra compressed packet WHY? let's say
|
||||
# len(payload) is MAX_PAYLOAD_LENGTH - 3; when preparing the payload, a
|
||||
# header of size PACKET_HEADER_LENGTH is pre-appended to the payload.
|
||||
# This means that len(payload_prep) is
|
||||
# MAX_PAYLOAD_LENGTH - 3 + PACKET_HEADER_LENGTH = MAX_PAYLOAD_LENGTH + 1
|
||||
# surpassing the maximum allowed payload size per packet.
|
||||
offset = 0
|
||||
|
||||
# Send several MySQL packets.
|
||||
for _ in range(len(payload_prep) // MAX_PAYLOAD_LENGTH):
|
||||
await asyncio.wait_for(
|
||||
self._write_pkt(
|
||||
writer,
|
||||
address,
|
||||
payload_prep[offset : offset + MAX_PAYLOAD_LENGTH],
|
||||
),
|
||||
write_timeout,
|
||||
)
|
||||
self._set_next_compressed_pktnr()
|
||||
offset += MAX_PAYLOAD_LENGTH
|
||||
await asyncio.wait_for(
|
||||
self._write_pkt(writer, address, payload_prep[offset:]),
|
||||
write_timeout,
|
||||
)
|
||||
else:
|
||||
# Send one MySQL packet.
|
||||
# For small packets it may be too costly to compress the packet.
|
||||
# Usually payloads less than 50 bytes (MIN_COMPRESS_LENGTH) aren't
|
||||
# compressed (see MySQL source code Documentation).
|
||||
if len(payload) > MIN_COMPRESS_LENGTH:
|
||||
# Perform compression.
|
||||
await asyncio.wait_for(
|
||||
self._write_pkt(writer, address, payload_prep), write_timeout
|
||||
)
|
||||
else:
|
||||
# Skip compression.
|
||||
await asyncio.wait_for(
|
||||
super()._write_pkt(
|
||||
writer,
|
||||
address,
|
||||
struct.pack("<I", len(payload_prep))[0:3]
|
||||
+ struct.pack("<B", self._compressed_pktnr)
|
||||
+ struct.pack("<I", 0)[0:3]
|
||||
+ payload_prep,
|
||||
),
|
||||
write_timeout,
|
||||
)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError) as err:
|
||||
raise WriteTimeoutError(errno=3024) from err
|
||||
|
||||
async def _read_compressed_pkt(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
compressed_pll: int,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Handle reading of a compressed packet."""
|
||||
# compressed_pll stands for compressed payload length.
|
||||
pkt = bytearray(
|
||||
zlib.decompress(
|
||||
await super()._read_chunk(reader, compressed_pll, read_timeout)
|
||||
)
|
||||
)
|
||||
offset = 0
|
||||
while offset < len(pkt):
|
||||
# pll stands for payload length
|
||||
pll = struct.unpack(
|
||||
"<I", pkt[offset : offset + PACKET_HEADER_LENGTH - 1] + b"\x00"
|
||||
)[0]
|
||||
if PACKET_HEADER_LENGTH + pll > len(pkt) - offset:
|
||||
# More bytes need to be consumed.
|
||||
# Read the header of the next MySQL packet.
|
||||
header = await super()._read_chunk(
|
||||
reader, COMPRESSED_PACKET_HEADER_LENGTH, read_timeout
|
||||
)
|
||||
|
||||
# compressed payload length, sequence id, uncompressed payload length.
|
||||
(
|
||||
compressed_pll,
|
||||
self._compressed_pktnr,
|
||||
uncompressed_pll,
|
||||
) = self.get_header(header)
|
||||
compressed_pkt = await super()._read_chunk(
|
||||
reader, compressed_pll, read_timeout
|
||||
)
|
||||
|
||||
# Recalling that if uncompressed payload length == 0, the packet comes
|
||||
# in uncompressed, so no decompression is needed.
|
||||
pkt += (
|
||||
compressed_pkt
|
||||
if uncompressed_pll == 0
|
||||
else zlib.decompress(compressed_pkt)
|
||||
)
|
||||
|
||||
self._queue_read.append(pkt[offset : offset + PACKET_HEADER_LENGTH + pll])
|
||||
offset += PACKET_HEADER_LENGTH + pll
|
||||
|
||||
async def read(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
address: str,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> bytearray:
|
||||
"""Receive `one` or `several` packets from the MySQL server, enqueue them, and
|
||||
return the packet at the head.
|
||||
"""
|
||||
|
||||
if not self._queue_read:
|
||||
try:
|
||||
# Read the header of the next MySQL packet.
|
||||
header = await super()._read_chunk(
|
||||
reader, COMPRESSED_PACKET_HEADER_LENGTH, read_timeout
|
||||
)
|
||||
|
||||
# compressed payload length, sequence id, uncompressed payload length
|
||||
(
|
||||
compressed_pll,
|
||||
self._compressed_pktnr,
|
||||
uncompressed_pll,
|
||||
) = self.get_header(header)
|
||||
|
||||
if uncompressed_pll == 0:
|
||||
# Packet is not compressed, so just store it.
|
||||
self._queue_read.append(
|
||||
await super()._read_chunk(reader, compressed_pll, read_timeout)
|
||||
)
|
||||
else:
|
||||
# Packet comes in compressed, further action is needed.
|
||||
await self._read_compressed_pkt(
|
||||
reader, compressed_pll, read_timeout
|
||||
)
|
||||
except IOError as err:
|
||||
raise OperationalError(
|
||||
errno=2055, values=(address, _strioerror(err))
|
||||
) from err
|
||||
|
||||
if not self._queue_read:
|
||||
return None
|
||||
|
||||
pkt = self._queue_read.popleft()
|
||||
self._pktnr = pkt[3]
|
||||
|
||||
return pkt
|
||||
|
||||
|
||||
class MySQLSocket(ABC):
|
||||
"""MySQL socket communication interface.
|
||||
|
||||
Examples:
|
||||
Subclasses: network.MySQLTCPSocket and network.MySQLUnixSocket.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Network layer where transactions are made with plain (uncompressed) packets
|
||||
is enabled by default.
|
||||
"""
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._writer: Optional[StreamWriter] = None
|
||||
self._connection_timeout: Optional[int] = None
|
||||
self._address: Optional[str] = None
|
||||
self._netbroker: NetworkBroker = NetworkBrokerPlain()
|
||||
self._is_connected: bool = False
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
"""Socket location."""
|
||||
return self._address
|
||||
|
||||
@abstractmethod
|
||||
async def open_connection(self, **kwargs: Any) -> None:
|
||||
"""Open the socket."""
|
||||
|
||||
async def close_connection(self) -> None:
|
||||
"""Close the connection."""
|
||||
if self._writer:
|
||||
try:
|
||||
self._writer.close()
|
||||
# Without transport.abort(), an error is raised when using SSL
|
||||
if self._writer.transport is not None:
|
||||
self._writer.transport.abort()
|
||||
await self._writer.wait_closed()
|
||||
except Exception as _: # pylint: disable=broad-exception-caught)
|
||||
# we can ignore issues like ConnectionRefused or ConnectionAborted
|
||||
# as these instances might popup if the connection was closed due to timeout issues
|
||||
pass
|
||||
self._is_connected = False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if the socket is connected.
|
||||
|
||||
Return:
|
||||
bool: Returns `True` if the socket is connected to MySQL server.
|
||||
"""
|
||||
return self._is_connected
|
||||
|
||||
def set_connection_timeout(self, timeout: int) -> None:
|
||||
"""Set the connection timeout."""
|
||||
self._connection_timeout = timeout
|
||||
|
||||
def switch_to_compressed_mode(self) -> None:
|
||||
"""Enable network layer where transactions are made with compressed packets."""
|
||||
self._netbroker = NetworkBrokerCompressed()
|
||||
|
||||
async def switch_to_ssl(self, ssl_context: ssl.SSLContext) -> None:
|
||||
"""Upgrade an existing stream-based connection to TLS.
|
||||
|
||||
The `start_tls()` method from `asyncio.streams.StreamWriter` is only available
|
||||
in Python 3.11. This method is used as a workaround.
|
||||
|
||||
The MySQL TLS negotiation happens in the middle of the TCP connection.
|
||||
Therefore, passing a socket to open connection will cause it to negotiate
|
||||
TLS on an existing connection.
|
||||
|
||||
Args:
|
||||
ssl_context: The SSL Context to be used.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the transport does not expose the socket instance.
|
||||
"""
|
||||
# Ensure that self._writer is already created
|
||||
assert self._writer is not None
|
||||
|
||||
socket = self._writer.transport.get_extra_info("socket")
|
||||
if socket.family == 1: # socket.AF_UNIX
|
||||
raise ProgrammingError("SSL is not supported when using Unix sockets")
|
||||
|
||||
await self._writer.start_tls(ssl_context)
|
||||
|
||||
async def write(
|
||||
self,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send packets to the MySQL server."""
|
||||
await self._netbroker.write(
|
||||
self._writer,
|
||||
self.address,
|
||||
payload,
|
||||
packet_number=packet_number,
|
||||
compressed_packet_number=compressed_packet_number,
|
||||
write_timeout=write_timeout,
|
||||
)
|
||||
|
||||
async def read(self, read_timeout: Optional[int] = None) -> bytearray:
|
||||
"""Read packets from the MySQL server."""
|
||||
return await self._netbroker.read(self._reader, self.address, read_timeout)
|
||||
|
||||
def build_ssl_context(
|
||||
self,
|
||||
ssl_ca: Optional[str] = None,
|
||||
ssl_cert: Optional[str] = None,
|
||||
ssl_key: Optional[str] = None,
|
||||
ssl_verify_cert: Optional[bool] = False,
|
||||
ssl_verify_identity: Optional[bool] = False,
|
||||
tls_versions: Optional[List[str]] = [],
|
||||
tls_cipher_suites: Optional[List[str]] = [],
|
||||
) -> ssl.SSLContext:
|
||||
"""Build a SSLContext."""
|
||||
tls_version: Optional[str] = None
|
||||
|
||||
if not self._reader:
|
||||
raise InterfaceError(errno=2048)
|
||||
|
||||
if ssl is None:
|
||||
raise RuntimeError("Python installation has no SSL support")
|
||||
|
||||
try:
|
||||
if tls_versions:
|
||||
tls_versions.sort(reverse=True)
|
||||
tls_version = tls_versions[0]
|
||||
ssl_protocol = TLS_VERSIONS[tls_version]
|
||||
context = ssl.SSLContext(ssl_protocol)
|
||||
|
||||
if tls_version == "TLSv1.3":
|
||||
if "TLSv1.2" not in tls_versions:
|
||||
context.options |= ssl.OP_NO_TLSv1_2
|
||||
if "TLSv1.1" not in tls_versions:
|
||||
context.options |= ssl.OP_NO_TLSv1_1
|
||||
if "TLSv1" not in tls_versions:
|
||||
context.options |= ssl.OP_NO_TLSv1
|
||||
else:
|
||||
context = ssl.create_default_context()
|
||||
|
||||
context.check_hostname = ssl_verify_identity
|
||||
|
||||
if ssl_verify_cert:
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
elif ssl_verify_identity:
|
||||
context.verify_mode = ssl.CERT_OPTIONAL
|
||||
else:
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
context.load_default_certs()
|
||||
|
||||
if ssl_ca:
|
||||
try:
|
||||
context.load_verify_locations(ssl_ca)
|
||||
except (IOError, ssl.SSLError) as err:
|
||||
raise InterfaceError(f"Invalid CA Certificate: {err}") from err
|
||||
if ssl_cert:
|
||||
try:
|
||||
context.load_cert_chain(ssl_cert, ssl_key)
|
||||
except (IOError, ssl.SSLError) as err:
|
||||
raise InterfaceError(f"Invalid Certificate/Key: {err}") from err
|
||||
|
||||
# TLSv1.3 ciphers cannot be disabled with `SSLContext.set_ciphers(...)`,
|
||||
# see https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_ciphers.
|
||||
if tls_cipher_suites and tls_version == "TLSv1.2":
|
||||
context.set_ciphers(":".join(tls_cipher_suites))
|
||||
|
||||
return context
|
||||
except NameError as err:
|
||||
raise NotSupportedError("Python installation has no SSL support") from err
|
||||
except (
|
||||
IOError,
|
||||
NotImplementedError,
|
||||
ssl.CertificateError,
|
||||
ssl.SSLError,
|
||||
) as err:
|
||||
raise InterfaceError(str(err)) from err
|
||||
|
||||
|
||||
class MySQLTcpSocket(MySQLSocket):
|
||||
"""MySQL socket class using TCP/IP.
|
||||
|
||||
Args:
|
||||
host: MySQL host name.
|
||||
port: MySQL port.
|
||||
force_ipv6: Force IPv6 usage.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, host: str = "127.0.0.1", port: int = 3306, force_ipv6: bool = False
|
||||
):
|
||||
super().__init__()
|
||||
self._host: str = host
|
||||
self._port: int = port
|
||||
self._force_ipv6: bool = force_ipv6
|
||||
self._address: str = f"{host}:{port}"
|
||||
|
||||
async def open_connection(self, **kwargs: Any) -> None:
|
||||
"""Open TCP/IP connection."""
|
||||
self._reader, self._writer = await open_connection(
|
||||
host=self._host, port=self._port, **kwargs
|
||||
)
|
||||
self._is_connected = True
|
||||
|
||||
|
||||
class MySQLUnixSocket(MySQLSocket):
|
||||
"""MySQL socket class using UNIX sockets.
|
||||
|
||||
Args:
|
||||
unix_socket: UNIX socket file path.
|
||||
"""
|
||||
|
||||
def __init__(self, unix_socket: str = "/tmp/mysql.sock"):
|
||||
super().__init__()
|
||||
self._address: str = unix_socket
|
||||
|
||||
async def open_connection(self, **kwargs: Any) -> None:
|
||||
"""Open UNIX socket connection."""
|
||||
(
|
||||
self._reader,
|
||||
self._writer,
|
||||
) = await asyncio.open_unix_connection( # type: ignore[assignment]
|
||||
path=self._address, **kwargs
|
||||
)
|
||||
self._is_connected = True
|
||||
@@ -0,0 +1,162 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Base Authentication Plugin class."""
|
||||
|
||||
__all__ = ["MySQLAuthPlugin", "get_auth_plugin"]
|
||||
|
||||
import importlib
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING, Any, Optional, Type
|
||||
|
||||
from mysql.connector.errors import NotSupportedError, ProgrammingError
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
DEFAULT_PLUGINS_PKG = "mysql.connector.aio.plugins"
|
||||
|
||||
|
||||
class MySQLAuthPlugin(ABC):
|
||||
"""Authorization plugin interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
ssl_enabled: bool = False,
|
||||
) -> None:
|
||||
"""Constructor."""
|
||||
self._username: str = "" if username is None else username
|
||||
self._password: str = "" if password is None else password
|
||||
self._ssl_enabled: bool = ssl_enabled
|
||||
|
||||
@property
|
||||
def ssl_enabled(self) -> bool:
|
||||
"""Signals whether or not SSL is enabled."""
|
||||
return self._ssl_enabled
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
|
||||
@abstractmethod
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Make the client's authorization response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
"""
|
||||
|
||||
async def auth_more_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth more data` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Authentication method data (from a packet representing
|
||||
an `auth more data` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth communication.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth communication.
|
||||
"""
|
||||
|
||||
|
||||
@lru_cache(maxsize=10, typed=False)
|
||||
def get_auth_plugin(
|
||||
plugin_name: str,
|
||||
auth_plugin_class: Optional[str] = None,
|
||||
) -> Type[MySQLAuthPlugin]:
|
||||
"""Return authentication class based on plugin name
|
||||
|
||||
This function returns the class for the authentication plugin plugin_name.
|
||||
The returned class is a subclass of BaseAuthPlugin.
|
||||
|
||||
Args:
|
||||
plugin_name (str): Authentication plugin name.
|
||||
auth_plugin_class (str): Authentication plugin class name.
|
||||
|
||||
Raises:
|
||||
NotSupportedError: When plugin_name is not supported.
|
||||
|
||||
Returns:
|
||||
Subclass of `MySQLAuthPlugin`.
|
||||
"""
|
||||
package = DEFAULT_PLUGINS_PKG
|
||||
if plugin_name:
|
||||
try:
|
||||
logger.info("package: %s", package)
|
||||
logger.info("plugin_name: %s", plugin_name)
|
||||
plugin_module = importlib.import_module(f".{plugin_name}", package)
|
||||
if not auth_plugin_class or not hasattr(plugin_module, auth_plugin_class):
|
||||
auth_plugin_class = plugin_module.AUTHENTICATION_PLUGIN_CLASS
|
||||
logger.info("AUTHENTICATION_PLUGIN_CLASS: %s", auth_plugin_class)
|
||||
return getattr(plugin_module, auth_plugin_class)
|
||||
except ModuleNotFoundError as err:
|
||||
logger.warning("Requested Module was not found: %s", err)
|
||||
except ValueError as err:
|
||||
raise ProgrammingError(f"Invalid module name: {err}") from err
|
||||
raise NotSupportedError(f"Authentication plugin '{plugin_name}' is not supported")
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="str-bytes-safe,misc"
|
||||
|
||||
"""Kerberos Authentication Plugin."""
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import struct
|
||||
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Optional, Tuple
|
||||
|
||||
from mysql.connector.errors import InterfaceError, ProgrammingError
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
from ..authentication import ERR_STATUS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
import gssapi
|
||||
except ImportError:
|
||||
gssapi = None
|
||||
if os.name != "nt":
|
||||
raise ProgrammingError(
|
||||
"Module gssapi is required for GSSAPI authentication "
|
||||
"mechanism but was not found. Unable to authenticate "
|
||||
"with the server"
|
||||
) from None
|
||||
|
||||
try:
|
||||
import sspi
|
||||
import sspicon
|
||||
except ImportError:
|
||||
sspi = None
|
||||
sspicon = None
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = (
|
||||
"MySQLSSPIKerberosAuthPlugin" if os.name == "nt" else "MySQLKerberosAuthPlugin"
|
||||
)
|
||||
|
||||
|
||||
class MySQLBaseKerberosAuthPlugin(MySQLAuthPlugin):
|
||||
"""Base class for the MySQL Kerberos authentication plugin."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_kerberos_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def auth_continue(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge: the challenge for the negotiation.
|
||||
|
||||
Returns:
|
||||
tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
logger.debug("# auth_data: %s", auth_data)
|
||||
response = self.auth_response(auth_data, ignore_auth_data=False, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
packet = await sock.read()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
if packet != ERR_STATUS:
|
||||
rcode_size = 5 # Reader size for the response status code
|
||||
logger.debug("# Continue with GSSAPI authentication")
|
||||
logger.debug("# Response header: %s", packet[: rcode_size + 1])
|
||||
logger.debug("# Response size: %s", len(packet))
|
||||
logger.debug("# Negotiate a service request")
|
||||
complete = False
|
||||
tries = 0
|
||||
|
||||
while not complete and tries < 5:
|
||||
logger.debug("%s Attempt %s %s", "-" * 20, tries + 1, "-" * 20)
|
||||
logger.debug("<< Server response: %s", packet)
|
||||
logger.debug("# Response code: %s", packet[: rcode_size + 1])
|
||||
token, complete = self.auth_continue(packet[rcode_size:])
|
||||
if token:
|
||||
await sock.write(token)
|
||||
if complete:
|
||||
break
|
||||
packet = await sock.read()
|
||||
|
||||
logger.debug(">> Response to server: %s", token)
|
||||
tries += 1
|
||||
|
||||
if not complete:
|
||||
raise InterfaceError(
|
||||
f"Unable to fulfill server request after {tries} "
|
||||
f"attempts. Last server response: {packet}"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Last response from server: %s length: %d",
|
||||
packet,
|
||||
len(packet),
|
||||
)
|
||||
|
||||
# Receive OK packet from server.
|
||||
packet = await sock.read()
|
||||
logger.debug("<< Ok packet from server: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
|
||||
|
||||
# pylint: disable=c-extension-no-member,no-member
|
||||
class MySQLKerberosAuthPlugin(MySQLBaseKerberosAuthPlugin):
|
||||
"""Implement the MySQL Kerberos authentication plugin."""
|
||||
|
||||
context: Optional[gssapi.SecurityContext] = None
|
||||
|
||||
@staticmethod
|
||||
def get_user_from_credentials() -> str:
|
||||
"""Get user from credentials without realm."""
|
||||
try:
|
||||
creds = gssapi.Credentials(usage="initiate")
|
||||
user = str(creds.name)
|
||||
if user.find("@") != -1:
|
||||
user, _ = user.split("@", 1)
|
||||
return user
|
||||
except gssapi.raw.misc.GSSError:
|
||||
return getpass.getuser()
|
||||
|
||||
@staticmethod
|
||||
def get_store() -> dict:
|
||||
"""Get a credentials store dictionary.
|
||||
|
||||
Returns:
|
||||
dict: Credentials store dictionary with the krb5 ccache name.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If 'KRB5CCNAME' environment variable is empty.
|
||||
"""
|
||||
krb5ccname = os.environ.get(
|
||||
"KRB5CCNAME",
|
||||
(
|
||||
f"/tmp/krb5cc_{os.getuid()}"
|
||||
if os.name == "posix"
|
||||
else Path("%TEMP%").joinpath("krb5cc")
|
||||
),
|
||||
)
|
||||
if not krb5ccname:
|
||||
raise InterfaceError(
|
||||
"The 'KRB5CCNAME' environment variable is set to empty"
|
||||
)
|
||||
logger.debug("Using krb5 ccache name: FILE:%s", krb5ccname)
|
||||
store = {b"ccache": f"FILE:{krb5ccname}".encode("utf-8")}
|
||||
return store
|
||||
|
||||
def _acquire_cred_with_password(self, upn: str) -> gssapi.raw.creds.Creds:
|
||||
"""Acquire and store credentials through provided password.
|
||||
|
||||
Args:
|
||||
upn (str): User Principal Name.
|
||||
|
||||
Returns:
|
||||
gssapi.raw.creds.Creds: GSSAPI credentials.
|
||||
"""
|
||||
logger.debug("Attempt to acquire credentials through provided password")
|
||||
user = gssapi.Name(upn, gssapi.NameType.user)
|
||||
password = self._password.encode("utf-8")
|
||||
|
||||
try:
|
||||
acquire_cred_result = gssapi.raw.acquire_cred_with_password(
|
||||
user, password, usage="initiate"
|
||||
)
|
||||
creds = acquire_cred_result.creds
|
||||
gssapi.raw.store_cred_into(
|
||||
self.get_store(),
|
||||
creds=creds,
|
||||
mech=gssapi.MechType.kerberos,
|
||||
overwrite=True,
|
||||
set_default=True,
|
||||
)
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
raise ProgrammingError(
|
||||
f"Unable to acquire credentials with the given password: {err}"
|
||||
) from err
|
||||
return creds
|
||||
|
||||
@staticmethod
|
||||
def _parse_auth_data(packet: bytes) -> Tuple[str, str]:
|
||||
"""Parse authentication data.
|
||||
|
||||
Get the SPN and REALM from the authentication data packet.
|
||||
|
||||
Format:
|
||||
SPN string length two bytes <B1> <B2> +
|
||||
SPN string +
|
||||
UPN realm string length two bytes <B1> <B2> +
|
||||
UPN realm string
|
||||
|
||||
Returns:
|
||||
tuple: With 'spn' and 'realm'.
|
||||
"""
|
||||
spn_len = struct.unpack("<H", packet[:2])[0]
|
||||
packet = packet[2:]
|
||||
|
||||
spn = struct.unpack(f"<{spn_len}s", packet[:spn_len])[0]
|
||||
packet = packet[spn_len:]
|
||||
|
||||
realm_len = struct.unpack("<H", packet[:2])[0]
|
||||
realm = struct.unpack(f"<{realm_len}s", packet[2:])[0]
|
||||
|
||||
return spn.decode(), realm.decode()
|
||||
|
||||
def auth_response(
|
||||
self, auth_data: Optional[bytes] = None, **kwargs: Any
|
||||
) -> Optional[bytes]:
|
||||
"""Prepare the first message to the server."""
|
||||
spn = None
|
||||
realm = None
|
||||
|
||||
if auth_data and not kwargs.get("ignore_auth_data", True):
|
||||
try:
|
||||
spn, realm = self._parse_auth_data(auth_data)
|
||||
except struct.error as err:
|
||||
raise InterruptedError(f"Invalid authentication data: {err}") from err
|
||||
|
||||
if spn is None:
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
upn = f"{self._username}@{realm}" if self._username else None
|
||||
|
||||
logger.debug("Service Principal: %s", spn)
|
||||
logger.debug("Realm: %s", realm)
|
||||
|
||||
try:
|
||||
# Attempt to retrieve credentials from cache file
|
||||
creds: Any = gssapi.Credentials(usage="initiate")
|
||||
creds_upn = str(creds.name)
|
||||
|
||||
logger.debug("Cached credentials found")
|
||||
logger.debug("Cached credentials UPN: %s", creds_upn)
|
||||
|
||||
# Remove the realm from user
|
||||
if creds_upn.find("@") != -1:
|
||||
creds_user, creds_realm = creds_upn.split("@", 1)
|
||||
else:
|
||||
creds_user = creds_upn
|
||||
creds_realm = None
|
||||
|
||||
upn = f"{self._username}@{realm}" if self._username else creds_upn
|
||||
|
||||
# The user from cached credentials matches with the given user?
|
||||
if self._username and self._username != creds_user:
|
||||
logger.debug(
|
||||
"The user from cached credentials doesn't match with the "
|
||||
"given user"
|
||||
)
|
||||
if self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
if creds_realm and creds_realm != realm and self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
except gssapi.raw.exceptions.ExpiredCredentialsError as err:
|
||||
if upn and self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
else:
|
||||
raise InterfaceError(f"Credentials has expired: {err}") from err
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
if upn and self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
else:
|
||||
raise InterfaceError(
|
||||
f"Unable to retrieve cached credentials error: {err}"
|
||||
) from err
|
||||
|
||||
flags = (
|
||||
gssapi.RequirementFlag.mutual_authentication,
|
||||
gssapi.RequirementFlag.extended_error,
|
||||
gssapi.RequirementFlag.delegate_to_peer,
|
||||
)
|
||||
name = gssapi.Name(spn, name_type=gssapi.NameType.kerberos_principal)
|
||||
cname = name.canonicalize(gssapi.MechType.kerberos)
|
||||
self.context = gssapi.SecurityContext(
|
||||
name=cname, creds=creds, flags=sum(flags), usage="initiate"
|
||||
)
|
||||
|
||||
try:
|
||||
initial_client_token: Optional[bytes] = self.context.step()
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
raise InterfaceError(f"Unable to initiate security context: {err}") from err
|
||||
|
||||
logger.debug("Initial client token: %s", initial_client_token)
|
||||
return initial_client_token
|
||||
|
||||
def auth_continue(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge: the challenge for the negotiation.
|
||||
|
||||
Returns:
|
||||
tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
logger.debug("tgt_auth challenge: %s", tgt_auth_challenge)
|
||||
|
||||
resp: Optional[bytes] = self.context.step(tgt_auth_challenge)
|
||||
|
||||
logger.debug("Context step response: %s", resp)
|
||||
logger.debug("Context completed?: %s", self.context.complete)
|
||||
|
||||
return resp, self.context.complete
|
||||
|
||||
def auth_accept_close_handshake(self, message: bytes) -> bytes:
|
||||
"""Accept handshake and generate closing handshake message for server.
|
||||
|
||||
This method verifies the server authenticity from the given message
|
||||
and included signature and generates the closing handshake for the
|
||||
server.
|
||||
|
||||
When this method is invoked the security context is already established
|
||||
and the client and server can send GSSAPI formated secure messages.
|
||||
|
||||
To finish the authentication handshake the server sends a message
|
||||
with the security layer availability and the maximum buffer size.
|
||||
|
||||
Since the connector only uses the GSSAPI authentication mechanism to
|
||||
authenticate the user with the server, the server will verify clients
|
||||
message signature and terminate the GSSAPI authentication and send two
|
||||
messages; an authentication acceptance b'\x01\x00\x00\x08\x01' and a
|
||||
OK packet (that must be received after sent the returned message from
|
||||
this method).
|
||||
|
||||
Args:
|
||||
message: a wrapped gssapi message from the server.
|
||||
|
||||
Returns:
|
||||
bytearray (closing handshake message to be send to the server).
|
||||
"""
|
||||
if not self.context.complete:
|
||||
raise ProgrammingError("Security context is not completed")
|
||||
logger.debug("Server message: %s", message)
|
||||
logger.debug("GSSAPI flags in use: %s", self.context.actual_flags)
|
||||
try:
|
||||
unwraped = self.context.unwrap(message)
|
||||
logger.debug("Unwraped: %s", unwraped)
|
||||
except gssapi.raw.exceptions.BadMICError as err:
|
||||
logger.debug("Unable to unwrap server message: %s", err)
|
||||
raise InterfaceError(f"Unable to unwrap server message: {err}") from err
|
||||
|
||||
logger.debug("Unwrapped server message: %s", unwraped)
|
||||
# The message contents for the clients closing message:
|
||||
# - security level 1 byte, must be always 1.
|
||||
# - conciliated buffer size 3 bytes, without importance as no
|
||||
# further GSSAPI messages will be sends.
|
||||
response = bytearray(b"\x01\x00\x00\00")
|
||||
# Closing handshake must not be encrypted.
|
||||
logger.debug("Message response: %s", response)
|
||||
wraped = self.context.wrap(response, encrypt=False)
|
||||
logger.debug(
|
||||
"Wrapped message response: %s, length: %d",
|
||||
wraped[0],
|
||||
len(wraped[0]),
|
||||
)
|
||||
|
||||
return wraped.message
|
||||
|
||||
|
||||
class MySQLSSPIKerberosAuthPlugin(MySQLBaseKerberosAuthPlugin):
|
||||
"""Implement the MySQL Kerberos authentication plugin with Windows SSPI"""
|
||||
|
||||
context: Any = None
|
||||
clientauth: Any = None
|
||||
|
||||
@staticmethod
|
||||
def _parse_auth_data(packet: bytes) -> Tuple[str, str]:
|
||||
"""Parse authentication data.
|
||||
|
||||
Get the SPN and REALM from the authentication data packet.
|
||||
|
||||
Format:
|
||||
SPN string length two bytes <B1> <B2> +
|
||||
SPN string +
|
||||
UPN realm string length two bytes <B1> <B2> +
|
||||
UPN realm string
|
||||
|
||||
Returns:
|
||||
tuple: With 'spn' and 'realm'.
|
||||
"""
|
||||
spn_len = struct.unpack("<H", packet[:2])[0]
|
||||
packet = packet[2:]
|
||||
|
||||
spn = struct.unpack(f"<{spn_len}s", packet[:spn_len])[0]
|
||||
packet = packet[spn_len:]
|
||||
|
||||
realm_len = struct.unpack("<H", packet[:2])[0]
|
||||
realm = struct.unpack(f"<{realm_len}s", packet[2:])[0]
|
||||
|
||||
return spn.decode(), realm.decode()
|
||||
|
||||
def auth_response(
|
||||
self, auth_data: Optional[bytes] = None, **kwargs: Any
|
||||
) -> Optional[bytes]:
|
||||
"""Prepare the first message to the server.
|
||||
|
||||
Args:
|
||||
kwargs:
|
||||
ignore_auth_data (bool): if True, the provided auth data is ignored.
|
||||
"""
|
||||
logger.debug("auth_response for sspi")
|
||||
spn = None
|
||||
realm = None
|
||||
|
||||
if auth_data and not kwargs.get("ignore_auth_data", True):
|
||||
try:
|
||||
spn, realm = self._parse_auth_data(auth_data)
|
||||
except struct.error as err:
|
||||
raise InterruptedError(f"Invalid authentication data: {err}") from err
|
||||
|
||||
logger.debug("Service Principal: %s", spn)
|
||||
logger.debug("Realm: %s", realm)
|
||||
|
||||
if sspicon is None or sspi is None:
|
||||
raise ProgrammingError(
|
||||
'Package "pywin32" (Python for Win32 (pywin32) extensions)'
|
||||
" is not installed."
|
||||
)
|
||||
|
||||
flags = (sspicon.ISC_REQ_MUTUAL_AUTH, sspicon.ISC_REQ_DELEGATE)
|
||||
|
||||
if self._username and self._password:
|
||||
_auth_info = (self._username, realm, self._password)
|
||||
else:
|
||||
_auth_info = None
|
||||
|
||||
targetspn = spn
|
||||
logger.debug("targetspn: %s", targetspn)
|
||||
logger.debug("_auth_info is None: %s", _auth_info is None)
|
||||
|
||||
# The Security Support Provider Interface (SSPI) is an interface
|
||||
# that allows us to choose from a set of SSPs available in the
|
||||
# system; the idea of SSPI is to keep interface consistent no
|
||||
# matter what back end (a.k.a., SSP) we choose.
|
||||
|
||||
# When using SSPI we should not use Kerberos directly as SSP,
|
||||
# as remarked in [2], but we can use it indirectly via another
|
||||
# SSP named Negotiate that acts as an application layer between
|
||||
# SSPI and the other SSPs [1].
|
||||
|
||||
# Negotiate can select between Kerberos and NTLM on the fly;
|
||||
# it chooses Kerberos unless it cannot be used by one of the
|
||||
# systems involved in the authentication or the calling
|
||||
# application did not provide sufficient information to use
|
||||
# Kerberos.
|
||||
|
||||
# prefix: https://docs.microsoft.com/en-us/windows/win32/secauthn
|
||||
# [1] prefix/microsoft-negotiate?source=recommendations
|
||||
# [2] prefix/microsoft-kerberos?source=recommendations
|
||||
self.clientauth = sspi.ClientAuth(
|
||||
"Negotiate",
|
||||
targetspn=targetspn,
|
||||
auth_info=_auth_info,
|
||||
scflags=sum(flags),
|
||||
datarep=sspicon.SECURITY_NETWORK_DREP,
|
||||
)
|
||||
|
||||
try:
|
||||
data = None
|
||||
err, out_buf = self.clientauth.authorize(data)
|
||||
logger.debug("Context step err: %s", err)
|
||||
logger.debug("Context step out_buf: %s", out_buf)
|
||||
logger.debug("Context completed?: %s", self.clientauth.authenticated)
|
||||
initial_client_token = out_buf[0].Buffer
|
||||
logger.debug("pkg_info: %s", self.clientauth.pkg_info)
|
||||
except Exception as err:
|
||||
raise InterfaceError(f"Unable to initiate security context: {err}") from err
|
||||
|
||||
logger.debug("Initial client token: %s", initial_client_token)
|
||||
return initial_client_token
|
||||
|
||||
def auth_continue(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge: the challenge for the negotiation.
|
||||
|
||||
Returns:
|
||||
tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
logger.debug("tgt_auth challenge: %s", tgt_auth_challenge)
|
||||
|
||||
err, out_buf = self.clientauth.authorize(tgt_auth_challenge)
|
||||
|
||||
logger.debug("Context step err: %s", err)
|
||||
logger.debug("Context step out_buf: %s", out_buf)
|
||||
resp = out_buf[0].Buffer
|
||||
logger.debug("Context step resp: %s", resp)
|
||||
logger.debug("Context completed?: %s", self.clientauth.authenticated)
|
||||
|
||||
return resp, self.clientauth.authenticated
|
||||
+595
@@ -0,0 +1,595 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""LDAP SASL Authentication Plugin."""
|
||||
|
||||
import hmac
|
||||
|
||||
from base64 import b64decode, b64encode
|
||||
from hashlib import sha1, sha256
|
||||
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple
|
||||
from uuid import uuid4
|
||||
|
||||
from mysql.connector.authentication import ERR_STATUS
|
||||
from mysql.connector.errors import InterfaceError, ProgrammingError
|
||||
from mysql.connector.logger import logger
|
||||
from mysql.connector.types import StrOrBytes
|
||||
from mysql.connector.utils import (
|
||||
normalize_unicode_string as norm_ustr,
|
||||
validate_normalized_unicode_string as valid_norm,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
import gssapi
|
||||
except ImportError:
|
||||
raise ProgrammingError(
|
||||
"Module gssapi is required for GSSAPI authentication "
|
||||
"mechanism but was not found. Unable to authenticate "
|
||||
"with the server"
|
||||
) from None
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLLdapSaslPasswordAuthPlugin"
|
||||
|
||||
|
||||
# pylint: disable=c-extension-no-member,no-member
|
||||
class MySQLLdapSaslPasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL ldap sasl authentication plugin.
|
||||
|
||||
The MySQL's ldap sasl authentication plugin support two authentication
|
||||
methods SCRAM-SHA-1 and GSSAPI (using Kerberos). This implementation only
|
||||
support SCRAM-SHA-1 and SCRAM-SHA-256.
|
||||
|
||||
SCRAM-SHA-1 amd SCRAM-SHA-256
|
||||
This method requires 2 messages from client and 2 responses from
|
||||
server.
|
||||
|
||||
The first message from client will be generated by prepare_password(),
|
||||
after receive the response from the server, it is required that this
|
||||
response is passed back to auth_continue() which will return the
|
||||
second message from the client. After send this second message to the
|
||||
server, the second server respond needs to be passed to auth_finalize()
|
||||
to finish the authentication process.
|
||||
"""
|
||||
|
||||
sasl_mechanisms: List[str] = ["SCRAM-SHA-1", "SCRAM-SHA-256", "GSSAPI"]
|
||||
def_digest_mode: Callable = sha1
|
||||
client_nonce: Optional[str] = None
|
||||
client_salt: Any = None
|
||||
server_salt: Optional[str] = None
|
||||
krb_service_principal: Optional[str] = None
|
||||
iterations: int = 0
|
||||
server_auth_var: Optional[str] = None
|
||||
target_name: Optional[gssapi.Name] = None
|
||||
ctx: gssapi.SecurityContext = None
|
||||
servers_first: Optional[str] = None
|
||||
server_nonce: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def _xor(bytes1: bytes, bytes2: bytes) -> bytes:
|
||||
return bytes([b1 ^ b2 for b1, b2 in zip(bytes1, bytes2)])
|
||||
|
||||
def _hmac(self, password: bytes, salt: bytes) -> bytes:
|
||||
digest_maker = hmac.new(password, salt, self.def_digest_mode)
|
||||
return digest_maker.digest()
|
||||
|
||||
def _hi(self, password: str, salt: bytes, count: int) -> bytes:
|
||||
"""Prepares Hi
|
||||
Hi(password, salt, iterations) where Hi(p,s,i) is defined as
|
||||
PBKDF2 (HMAC, p, s, i, output length of H).
|
||||
"""
|
||||
pw = password.encode()
|
||||
hi = self._hmac(pw, salt + b"\x00\x00\x00\x01")
|
||||
aux = hi
|
||||
for _ in range(count - 1):
|
||||
aux = self._hmac(pw, aux)
|
||||
hi = self._xor(hi, aux)
|
||||
return hi
|
||||
|
||||
@staticmethod
|
||||
def _normalize(string: str) -> str:
|
||||
norm_str = norm_ustr(string)
|
||||
broken_rule = valid_norm(norm_str)
|
||||
if broken_rule is not None:
|
||||
raise InterfaceError(f"broken_rule: {broken_rule}")
|
||||
return norm_str
|
||||
|
||||
def _first_message(self) -> bytes:
|
||||
"""This method generates the first message to the server to start the
|
||||
|
||||
The client-first message consists of a gs2-header,
|
||||
the desired username, and a randomly generated client nonce cnonce.
|
||||
|
||||
The first message from the server has the form:
|
||||
b'n,a=<user_name>,n=<user_name>,r=<client_nonce>
|
||||
|
||||
Returns client's first message
|
||||
"""
|
||||
cfm_fprnat = "n,a={user_name},n={user_name},r={client_nonce}"
|
||||
self.client_nonce = str(uuid4()).replace("-", "")
|
||||
cfm: StrOrBytes = cfm_fprnat.format(
|
||||
user_name=self._normalize(self._username),
|
||||
client_nonce=self.client_nonce,
|
||||
)
|
||||
|
||||
if isinstance(cfm, str):
|
||||
cfm = cfm.encode("utf8")
|
||||
return cfm
|
||||
|
||||
def _first_message_krb(self) -> Optional[bytes]:
|
||||
"""Get a TGT Authentication request and initiates security context.
|
||||
|
||||
This method will contact the Kerberos KDC in order of obtain a TGT.
|
||||
"""
|
||||
user_name = gssapi.raw.names.import_name(
|
||||
self._username.encode("utf8"), name_type=gssapi.NameType.user
|
||||
)
|
||||
|
||||
# Use defaults store = {'ccache': 'FILE:/tmp/krb5cc_1000'}#,
|
||||
# 'keytab':'/etc/some.keytab' }
|
||||
# Attempt to retrieve credential from default cache file.
|
||||
try:
|
||||
cred: Any = gssapi.Credentials()
|
||||
logger.debug(
|
||||
"# Stored credentials found, if password was given it will be ignored."
|
||||
)
|
||||
try:
|
||||
# validate credentials has not expired.
|
||||
cred.lifetime
|
||||
except gssapi.raw.exceptions.ExpiredCredentialsError as err:
|
||||
logger.warning(" Credentials has expired: %s", err)
|
||||
cred.acquire(user_name)
|
||||
raise InterfaceError(f"Credentials has expired: {err}") from err
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
if not self._password:
|
||||
raise InterfaceError(
|
||||
f"Unable to retrieve stored credentials error: {err}"
|
||||
) from err
|
||||
try:
|
||||
logger.debug("# Attempt to retrieve credentials with given password")
|
||||
acquire_cred_result = gssapi.raw.acquire_cred_with_password(
|
||||
user_name,
|
||||
self._password.encode("utf8"),
|
||||
usage="initiate",
|
||||
)
|
||||
cred = acquire_cred_result[0]
|
||||
except gssapi.raw.misc.GSSError as err2:
|
||||
raise ProgrammingError(
|
||||
f"Unable to retrieve credentials with the given password: {err2}"
|
||||
) from err
|
||||
|
||||
flags_l = (
|
||||
gssapi.RequirementFlag.mutual_authentication,
|
||||
gssapi.RequirementFlag.extended_error,
|
||||
gssapi.RequirementFlag.delegate_to_peer,
|
||||
)
|
||||
|
||||
if self.krb_service_principal:
|
||||
service_principal = self.krb_service_principal
|
||||
else:
|
||||
service_principal = "ldap/ldapauth"
|
||||
logger.debug("# service principal: %s", service_principal)
|
||||
servk = gssapi.Name(
|
||||
service_principal, name_type=gssapi.NameType.kerberos_principal
|
||||
)
|
||||
self.target_name = servk
|
||||
self.ctx = gssapi.SecurityContext(
|
||||
name=servk, creds=cred, flags=sum(flags_l), usage="initiate"
|
||||
)
|
||||
|
||||
try:
|
||||
# step() returns bytes | None, see documentation,
|
||||
# so this method could return a NULL payload.
|
||||
# ref: https://pythongssapi.github.io/<suffix>
|
||||
# suffix: python-gssapi/latest/gssapi.html#gssapi.sec_contexts.SecurityContext
|
||||
initial_client_token = self.ctx.step()
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
raise InterfaceError(f"Unable to initiate security context: {err}") from err
|
||||
|
||||
logger.debug("# initial client token: %s", initial_client_token)
|
||||
return initial_client_token
|
||||
|
||||
def auth_continue_krb(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge the challenge for the negotiation.
|
||||
|
||||
Returns: tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
logger.debug("tgt_auth challenge: %s", tgt_auth_challenge)
|
||||
|
||||
resp = self.ctx.step(tgt_auth_challenge)
|
||||
logger.debug("# context step response: %s", resp)
|
||||
logger.debug("# context completed?: %s", self.ctx.complete)
|
||||
|
||||
return resp, self.ctx.complete
|
||||
|
||||
def auth_accept_close_handshake(self, message: bytes) -> bytes:
|
||||
"""Accept handshake and generate closing handshake message for server.
|
||||
|
||||
This method verifies the server authenticity from the given message
|
||||
and included signature and generates the closing handshake for the
|
||||
server.
|
||||
|
||||
When this method is invoked the security context is already established
|
||||
and the client and server can send GSSAPI formated secure messages.
|
||||
|
||||
To finish the authentication handshake the server sends a message
|
||||
with the security layer availability and the maximum buffer size.
|
||||
|
||||
Since the connector only uses the GSSAPI authentication mechanism to
|
||||
authenticate the user with the server, the server will verify clients
|
||||
message signature and terminate the GSSAPI authentication and send two
|
||||
messages; an authentication acceptance b'\x01\x00\x00\x08\x01' and a
|
||||
OK packet (that must be received after sent the returned message from
|
||||
this method).
|
||||
|
||||
Args:
|
||||
message a wrapped hssapi message from the server.
|
||||
|
||||
Returns: bytearray closing handshake message to be send to the server.
|
||||
"""
|
||||
if not self.ctx.complete:
|
||||
raise ProgrammingError("Security context is not completed.")
|
||||
logger.debug("# servers message: %s", message)
|
||||
logger.debug("# GSSAPI flags in use: %s", self.ctx.actual_flags)
|
||||
try:
|
||||
unwraped = self.ctx.unwrap(message)
|
||||
logger.debug("# unwraped: %s", unwraped)
|
||||
except gssapi.raw.exceptions.BadMICError as err:
|
||||
raise InterfaceError(f"Unable to unwrap server message: {err}") from err
|
||||
|
||||
logger.debug("# unwrapped server message: %s", unwraped)
|
||||
# The message contents for the clients closing message:
|
||||
# - security level 1 byte, must be always 1.
|
||||
# - conciliated buffer size 3 bytes, without importance as no
|
||||
# further GSSAPI messages will be sends.
|
||||
response = bytearray(b"\x01\x00\x00\00")
|
||||
# Closing handshake must not be encrypted.
|
||||
logger.debug("# message response: %s", response)
|
||||
wraped = self.ctx.wrap(response, encrypt=False)
|
||||
logger.debug(
|
||||
"# wrapped message response: %s, length: %d",
|
||||
wraped[0],
|
||||
len(wraped[0]),
|
||||
)
|
||||
|
||||
return wraped.message
|
||||
|
||||
def auth_response(
|
||||
self,
|
||||
auth_data: bytes,
|
||||
**kwargs: Any,
|
||||
) -> Optional[bytes]:
|
||||
"""This method will prepare the fist message to the server.
|
||||
|
||||
Returns bytes to send to the server as the first message.
|
||||
"""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self._auth_data = auth_data
|
||||
|
||||
auth_mechanism = self._auth_data.decode()
|
||||
logger.debug("read_method_name_from_server: %s", auth_mechanism)
|
||||
if auth_mechanism not in self.sasl_mechanisms:
|
||||
auth_mechanisms = '", "'.join(self.sasl_mechanisms[:-1])
|
||||
raise InterfaceError(
|
||||
f'The sasl authentication method "{auth_mechanism}" requested '
|
||||
f'from the server is not supported. Only "{auth_mechanisms}" '
|
||||
f'and "{self.sasl_mechanisms[-1]}" are supported'
|
||||
)
|
||||
|
||||
if b"GSSAPI" in self._auth_data:
|
||||
return self._first_message_krb()
|
||||
|
||||
if self._auth_data == b"SCRAM-SHA-256":
|
||||
self.def_digest_mode = sha256
|
||||
|
||||
return self._first_message()
|
||||
|
||||
def _second_message(self) -> bytes:
|
||||
"""This method generates the second message to the server
|
||||
|
||||
Second message consist on the concatenation of the client and the
|
||||
server nonce, and cproof.
|
||||
|
||||
c=<n,a=<user_name>>,r=<server_nonce>,p=<client_proof>
|
||||
where:
|
||||
<client_proof>: xor(<client_key>, <client_signature>)
|
||||
|
||||
<client_key>: hmac(salted_password, b"Client Key")
|
||||
<client_signature>: hmac(<stored_key>, <auth_msg>)
|
||||
<stored_key>: h(<client_key>)
|
||||
<auth_msg>: <client_first_no_header>,<servers_first>,
|
||||
c=<client_header>,r=<server_nonce>
|
||||
<client_first_no_header>: n=<username>r=<client_nonce>
|
||||
"""
|
||||
if not self._auth_data:
|
||||
raise InterfaceError("Missing authentication data (seed)")
|
||||
|
||||
passw = self._normalize(self._password)
|
||||
salted_password = self._hi(passw, b64decode(self.server_salt), self.iterations)
|
||||
logger.debug("salted_password: %s", b64encode(salted_password).decode())
|
||||
|
||||
client_key = self._hmac(salted_password, b"Client Key")
|
||||
logger.debug("client_key: %s", b64encode(client_key).decode())
|
||||
|
||||
stored_key = self.def_digest_mode(client_key).digest()
|
||||
logger.debug("stored_key: %s", b64encode(stored_key).decode())
|
||||
|
||||
server_key = self._hmac(salted_password, b"Server Key")
|
||||
logger.debug("server_key: %s", b64encode(server_key).decode())
|
||||
|
||||
client_first_no_header = ",".join(
|
||||
[
|
||||
f"n={self._normalize(self._username)}",
|
||||
f"r={self.client_nonce}",
|
||||
]
|
||||
)
|
||||
logger.debug("client_first_no_header: %s", client_first_no_header)
|
||||
|
||||
client_header = b64encode(
|
||||
f"n,a={self._normalize(self._username)},".encode()
|
||||
).decode()
|
||||
|
||||
auth_msg = ",".join(
|
||||
[
|
||||
client_first_no_header,
|
||||
self.servers_first,
|
||||
f"c={client_header}",
|
||||
f"r={self.server_nonce}",
|
||||
]
|
||||
)
|
||||
logger.debug("auth_msg: %s", auth_msg)
|
||||
|
||||
client_signature = self._hmac(stored_key, auth_msg.encode())
|
||||
logger.debug("client_signature: %s", b64encode(client_signature).decode())
|
||||
|
||||
client_proof = self._xor(client_key, client_signature)
|
||||
logger.debug("client_proof: %s", b64encode(client_proof).decode())
|
||||
|
||||
self.server_auth_var = b64encode(
|
||||
self._hmac(server_key, auth_msg.encode())
|
||||
).decode()
|
||||
logger.debug("server_auth_var: %s", self.server_auth_var)
|
||||
|
||||
msg = ",".join(
|
||||
[
|
||||
f"c={client_header}",
|
||||
f"r={self.server_nonce}",
|
||||
f"p={b64encode(client_proof).decode()}",
|
||||
]
|
||||
)
|
||||
logger.debug("second_message: %s", msg)
|
||||
return msg.encode()
|
||||
|
||||
def _validate_first_reponse(self, servers_first: bytes) -> None:
|
||||
"""Validates first message from the server.
|
||||
|
||||
Extracts the server's salt and iterations from the servers 1st response.
|
||||
First message from the server is in the form:
|
||||
<server_salt>,i=<iterations>
|
||||
"""
|
||||
if not servers_first or not isinstance(servers_first, (bytearray, bytes)):
|
||||
raise InterfaceError(f"Unexpected server message: {repr(servers_first)}")
|
||||
try:
|
||||
servers_first_str = servers_first.decode()
|
||||
self.servers_first = servers_first_str
|
||||
r_server_nonce, s_salt, i_counter = servers_first_str.split(",")
|
||||
except ValueError:
|
||||
raise InterfaceError(
|
||||
f"Unexpected server message: {servers_first_str}"
|
||||
) from None
|
||||
if (
|
||||
not r_server_nonce.startswith("r=")
|
||||
or not s_salt.startswith("s=")
|
||||
or not i_counter.startswith("i=")
|
||||
):
|
||||
raise InterfaceError(
|
||||
f"Incomplete reponse from the server: {servers_first_str}"
|
||||
)
|
||||
if self.client_nonce in r_server_nonce:
|
||||
self.server_nonce = r_server_nonce[2:]
|
||||
logger.debug("server_nonce: %s", self.server_nonce)
|
||||
else:
|
||||
raise InterfaceError(
|
||||
"Unable to authenticate response: response not well formed "
|
||||
f"{servers_first_str}"
|
||||
)
|
||||
self.server_salt = s_salt[2:]
|
||||
logger.debug(
|
||||
"server_salt: %s length: %s",
|
||||
self.server_salt,
|
||||
len(self.server_salt),
|
||||
)
|
||||
try:
|
||||
i_counter = i_counter[2:]
|
||||
logger.debug("iterations: %s", i_counter)
|
||||
self.iterations = int(i_counter)
|
||||
except Exception as err:
|
||||
raise InterfaceError(
|
||||
f"Unable to authenticate: iterations not found {servers_first_str}"
|
||||
) from err
|
||||
|
||||
def auth_continue(self, servers_first_response: bytes) -> bytes:
|
||||
"""return the second message from the client.
|
||||
|
||||
Returns bytes to send to the server as the second message.
|
||||
"""
|
||||
self._validate_first_reponse(servers_first_response)
|
||||
return self._second_message()
|
||||
|
||||
def _validate_second_reponse(self, servers_second: bytearray) -> bool:
|
||||
"""Validates second message from the server.
|
||||
|
||||
The client and the server prove to each other they have the same Auth
|
||||
variable.
|
||||
|
||||
The second message from the server consist of the server's proof:
|
||||
server_proof = HMAC(<server_key>, <auth_msg>)
|
||||
where:
|
||||
<server_key>: hmac(<salted_password>, b"Server Key")
|
||||
<auth_msg>: <client_first_no_header>,<servers_first>,
|
||||
c=<client_header>,r=<server_nonce>
|
||||
|
||||
Our server_proof must be equal to the Auth variable send on this second
|
||||
response.
|
||||
"""
|
||||
if (
|
||||
not servers_second
|
||||
or not isinstance(servers_second, bytearray)
|
||||
or len(servers_second) <= 2
|
||||
or not servers_second.startswith(b"v=")
|
||||
):
|
||||
raise InterfaceError("The server's proof is not well formated")
|
||||
server_var = servers_second[2:].decode()
|
||||
logger.debug("server auth variable: %s", server_var)
|
||||
return self.server_auth_var == server_var
|
||||
|
||||
def auth_finalize(self, servers_second_response: bytearray) -> bool:
|
||||
"""finalize the authentication process.
|
||||
|
||||
Raises InterfaceError if the ervers_second_response is invalid.
|
||||
|
||||
Returns True in successful authentication False otherwise.
|
||||
"""
|
||||
if not self._validate_second_reponse(servers_second_response):
|
||||
raise InterfaceError(
|
||||
"Authentication failed: Unable to proof server identity"
|
||||
)
|
||||
return True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_ldap_sasl_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
logger.debug("# auth_data: %s", auth_data)
|
||||
self.krb_service_principal = kwargs.get("krb_service_principal")
|
||||
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
packet = await sock.read()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
if len(packet) >= 6 and packet[5] == 114 and packet[6] == 61: # 'r' and '='
|
||||
# Continue with sasl authentication
|
||||
dec_response = packet[5:]
|
||||
cresponse = self.auth_continue(dec_response)
|
||||
await sock.write(cresponse)
|
||||
packet = await sock.read()
|
||||
if packet[5] == 118 and packet[6] == 61: # 'v' and '='
|
||||
if self.auth_finalize(packet[5:]):
|
||||
# receive packed OK
|
||||
packet = await sock.read()
|
||||
elif auth_data == b"GSSAPI" and packet[4] != ERR_STATUS:
|
||||
rcode_size = 5 # header size for the response status code.
|
||||
logger.debug("# Continue with sasl GSSAPI authentication")
|
||||
logger.debug("# response header: %s", packet[: rcode_size + 1])
|
||||
logger.debug("# response size: %s", len(packet))
|
||||
|
||||
logger.debug("# Negotiate a service request")
|
||||
complete = False
|
||||
tries = 0 # To avoid a infinite loop attempt no more than feedback messages
|
||||
while not complete and tries < 5:
|
||||
logger.debug("%s Attempt %s %s", "-" * 20, tries + 1, "-" * 20)
|
||||
logger.debug("<< server response: %s", packet)
|
||||
logger.debug("# response code: %s", packet[: rcode_size + 1])
|
||||
step, complete = self.auth_continue_krb(packet[rcode_size:])
|
||||
logger.debug(" >> response to server: %s", step)
|
||||
await sock.write(step or b"")
|
||||
packet = await sock.read()
|
||||
tries += 1
|
||||
if not complete:
|
||||
raise InterfaceError(
|
||||
f"Unable to fulfill server request after {tries} "
|
||||
f"attempts. Last server response: {packet}"
|
||||
)
|
||||
logger.debug(
|
||||
" last GSSAPI response from server: %s length: %d",
|
||||
packet,
|
||||
len(packet),
|
||||
)
|
||||
last_step = self.auth_accept_close_handshake(packet[rcode_size:])
|
||||
logger.debug(
|
||||
" >> last response to server: %s length: %d",
|
||||
last_step,
|
||||
len(last_step),
|
||||
)
|
||||
await sock.write(last_step)
|
||||
# Receive final handshake from server
|
||||
packet = await sock.read()
|
||||
logger.debug("<< final handshake from server: %s", packet)
|
||||
|
||||
# receive OK packet from server.
|
||||
packet = await sock.read()
|
||||
logger.debug("<< ok packet from server: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
|
||||
|
||||
# pylint: enable=c-extension-no-member,no-member
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="arg-type,union-attr,call-arg"
|
||||
|
||||
"""OCI Authentication Plugin."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from mysql.connector import errors
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
from cryptography.exceptions import UnsupportedAlgorithm
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.asymmetric.types import PRIVATE_KEY_TYPES
|
||||
except ImportError:
|
||||
raise errors.ProgrammingError("Package 'cryptography' is not installed") from None
|
||||
|
||||
try:
|
||||
from oci import config, exceptions
|
||||
except ImportError:
|
||||
raise errors.ProgrammingError(
|
||||
"Package 'oci' (Oracle Cloud Infrastructure Python SDK) is not installed"
|
||||
) from None
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLOCIAuthPlugin"
|
||||
OCI_SECURITY_TOKEN_MAX_SIZE = 10 * 1024 # In bytes
|
||||
OCI_SECURITY_TOKEN_TOO_LARGE = "Ephemeral security token is too large (10KB max)"
|
||||
OCI_SECURITY_TOKEN_FILE_NOT_AVAILABLE = (
|
||||
"Ephemeral security token file ('security_token_file') could not be read"
|
||||
)
|
||||
OCI_PROFILE_MISSING_PROPERTIES = (
|
||||
"OCI configuration file does not contain a 'fingerprint' or 'key_file' entry"
|
||||
)
|
||||
|
||||
|
||||
class MySQLOCIAuthPlugin(MySQLAuthPlugin):
|
||||
"""Implement the MySQL OCI IAM authentication plugin."""
|
||||
|
||||
context: Any = None
|
||||
oci_config_profile: str = "DEFAULT"
|
||||
oci_config_file: str = config.DEFAULT_LOCATION
|
||||
|
||||
@staticmethod
|
||||
def _prepare_auth_response(signature: bytes, oci_config: Dict[str, Any]) -> str:
|
||||
"""Prepare client's authentication response
|
||||
|
||||
Prepares client's authentication response in JSON format
|
||||
Args:
|
||||
signature (bytes): server's nonce to be signed by client.
|
||||
oci_config (dict): OCI configuration object.
|
||||
|
||||
Returns:
|
||||
str: JSON string with the following format:
|
||||
{"fingerprint": str, "signature": str, "token": base64.base64.base64}
|
||||
|
||||
Raises:
|
||||
ProgrammingError: If the ephemeral security token file can't be open or the
|
||||
token is too large.
|
||||
"""
|
||||
signature_64 = b64encode(signature)
|
||||
auth_response = {
|
||||
"fingerprint": oci_config["fingerprint"],
|
||||
"signature": signature_64.decode(),
|
||||
}
|
||||
|
||||
# The security token, if it exists, should be a JWT (JSON Web Token), consisted
|
||||
# of a base64-encoded header, body, and signature, separated by '.',
|
||||
# e.g. "Base64.Base64.Base64", stored in a file at the path specified by the
|
||||
# security_token_file configuration property
|
||||
if oci_config.get("security_token_file"):
|
||||
try:
|
||||
security_token_file = Path(oci_config["security_token_file"])
|
||||
# Check if token exceeds the maximum size
|
||||
if security_token_file.stat().st_size > OCI_SECURITY_TOKEN_MAX_SIZE:
|
||||
raise errors.ProgrammingError(OCI_SECURITY_TOKEN_TOO_LARGE)
|
||||
auth_response["token"] = security_token_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as err:
|
||||
raise errors.ProgrammingError(
|
||||
OCI_SECURITY_TOKEN_FILE_NOT_AVAILABLE
|
||||
) from err
|
||||
return json.dumps(auth_response, separators=(",", ":"))
|
||||
|
||||
@staticmethod
|
||||
def _get_private_key(key_path: str) -> PRIVATE_KEY_TYPES:
|
||||
"""Get the private_key form the given location"""
|
||||
try:
|
||||
with open(os.path.expanduser(key_path), "rb") as key_file:
|
||||
private_key = serialization.load_pem_private_key(
|
||||
key_file.read(),
|
||||
password=None,
|
||||
)
|
||||
except (TypeError, OSError, ValueError, UnsupportedAlgorithm) as err:
|
||||
raise errors.ProgrammingError(
|
||||
"An error occurred while reading the API_KEY from "
|
||||
f'"{key_path}": {err}'
|
||||
)
|
||||
|
||||
return private_key
|
||||
|
||||
def _get_valid_oci_config(self) -> Dict[str, Any]:
|
||||
"""Get a valid OCI config from the given configuration file path"""
|
||||
error_list = []
|
||||
req_keys = {
|
||||
"fingerprint": (lambda x: len(x) > 32),
|
||||
"key_file": (lambda x: os.path.exists(os.path.expanduser(x))),
|
||||
}
|
||||
|
||||
oci_config: Dict[str, Any] = {}
|
||||
try:
|
||||
# key_file is validated by oci.config if present
|
||||
oci_config = config.from_file(
|
||||
self.oci_config_file or config.DEFAULT_LOCATION,
|
||||
self.oci_config_profile or "DEFAULT",
|
||||
)
|
||||
for req_key, req_value in req_keys.items():
|
||||
try:
|
||||
# Verify parameter in req_key is present and valid
|
||||
if oci_config[req_key] and not req_value(oci_config[req_key]):
|
||||
error_list.append(f'Parameter "{req_key}" is invalid')
|
||||
except KeyError:
|
||||
error_list.append(f"Does not contain parameter {req_key}")
|
||||
except (
|
||||
exceptions.ConfigFileNotFound,
|
||||
exceptions.InvalidConfig,
|
||||
exceptions.InvalidKeyFilePath,
|
||||
exceptions.InvalidPrivateKey,
|
||||
exceptions.ProfileNotFound,
|
||||
) as err:
|
||||
error_list.append(str(err))
|
||||
|
||||
# Raise errors if any
|
||||
if error_list:
|
||||
raise errors.ProgrammingError(
|
||||
f"Invalid oci-config-file: {self.oci_config_file}. "
|
||||
f"Errors found: {error_list}"
|
||||
)
|
||||
|
||||
return oci_config
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_oci_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Prepare authentication string for the server."""
|
||||
logger.debug("server nonce: %s, len %d", auth_data, len(auth_data))
|
||||
|
||||
oci_config = self._get_valid_oci_config()
|
||||
|
||||
private_key = self._get_private_key(oci_config["key_file"])
|
||||
signature = private_key.sign(auth_data, padding.PKCS1v15(), hashes.SHA256())
|
||||
|
||||
auth_response = self._prepare_auth_response(signature, oci_config)
|
||||
logger.debug("authentication response: %s", auth_response)
|
||||
return auth_response.encode()
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
self.oci_config_file = kwargs.get("oci_config_file", "DEFAULT")
|
||||
self.oci_config_profile = kwargs.get(
|
||||
"oci_config_profile", config.DEFAULT_LOCATION
|
||||
)
|
||||
logger.debug("# oci configuration file path: %s", self.oci_config_file)
|
||||
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
packet = await sock.read()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# Copyright (c) 2024, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""OpenID Authentication Plugin."""
|
||||
|
||||
import re
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
from mysql.connector import errors, utils
|
||||
from mysql.connector.aio.network import MySQLSocket
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLOpenIDConnectAuthPlugin"
|
||||
OPENID_TOKEN_MAX_SIZE = 10 * 1024 # In bytes
|
||||
|
||||
|
||||
class MySQLOpenIDConnectAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL OpenID Connect Authentication Plugin."""
|
||||
|
||||
_openid_capability_flag: bytes = utils.int1store(1)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_openid_connect_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _validate_openid_token(token: str) -> bool:
|
||||
"""Helper method used to validate OpenID Connect token
|
||||
|
||||
The Token is represented as a JSON Web Token (JWT) consists of a
|
||||
base64-encoded header, body, and signature, separated by '.' e.g.,
|
||||
"Base64url.Base64url.Base64url". The First part of the token contains
|
||||
the header, the second part contains payload and the third part contains
|
||||
signature. These token parts should be Base64 URLSafe i.e., Token cannot
|
||||
contain characters other than a-z, A-Z, 0-9 and special characters '-', '_'.
|
||||
|
||||
Args:
|
||||
token (str): Base64url-encoded OpenID connect token fetched from
|
||||
the file path passed via `openid_token_file` connection
|
||||
argument.
|
||||
|
||||
Returns:
|
||||
bool: Signal indicating whether the token is valid or not.
|
||||
"""
|
||||
header_payload_sig: List[str] = token.split(".")
|
||||
if len(header_payload_sig) != 3:
|
||||
# invalid structure
|
||||
return False
|
||||
urlsafe_pattern = re.compile("^[a-zA-Z0-9-_]*$")
|
||||
return all(
|
||||
(
|
||||
len(token_part) and urlsafe_pattern.search(token_part) is not None
|
||||
for token_part in header_payload_sig
|
||||
)
|
||||
)
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> bytes:
|
||||
"""Prepares authentication string for the server.
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
The OpenID Connect authorization response follows the pattern :-
|
||||
int<1> capability flag
|
||||
string<lenenc> id token
|
||||
|
||||
Raises:
|
||||
InterfaceError: If the connection is insecure or the OpenID Token is too large,
|
||||
invalid or non-existent.
|
||||
ProgrammingError: If the OpenID Token file could not be read.
|
||||
"""
|
||||
try:
|
||||
# Check if the connection is secure
|
||||
if self.requires_ssl and not self._ssl_enabled:
|
||||
raise errors.InterfaceError(f"{self.name} requires SSL")
|
||||
|
||||
# Validate the file
|
||||
token_file_path: str = kwargs.get("openid_token_file", None)
|
||||
openid_token_file: Path = Path(token_file_path)
|
||||
# Check if token exceeds the maximum size
|
||||
if openid_token_file.stat().st_size > OPENID_TOKEN_MAX_SIZE:
|
||||
raise errors.InterfaceError(
|
||||
"The OpenID Connect token file size is too large (> 10KB)"
|
||||
)
|
||||
openid_token: str = openid_token_file.read_text(encoding="utf-8")
|
||||
openid_token = openid_token.strip()
|
||||
# Validate the JWT Token
|
||||
if not self._validate_openid_token(openid_token):
|
||||
raise errors.InterfaceError("The OpenID Connect Token is invalid")
|
||||
|
||||
# build the auth_response packet
|
||||
auth_response: List[bytes] = [
|
||||
self._openid_capability_flag,
|
||||
utils.lc_int(len(openid_token)),
|
||||
openid_token.encode(),
|
||||
]
|
||||
return b"".join(auth_response)
|
||||
except (SyntaxError, TypeError, OSError, UnicodeError) as err:
|
||||
raise errors.ProgrammingError(
|
||||
"The OpenID Connect Token File (openid_token_file) could not be read"
|
||||
) from err
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: MySQLSocket, auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If a NULL auth response is received from auth_response method.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
packet = await sock.read()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""WebAuthn Authentication Plugin."""
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
from mysql.connector import errors, utils
|
||||
|
||||
from ..logger import logger
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
from fido2.cbor import dump_bytes as cbor_dump_bytes
|
||||
from fido2.client import Fido2Client, UserInteraction
|
||||
from fido2.hid import CtapHidDevice
|
||||
from fido2.webauthn import PublicKeyCredentialRequestOptions
|
||||
except ImportError as import_err:
|
||||
raise errors.ProgrammingError(
|
||||
"Module fido2 is required for WebAuthn authentication mechanism but was "
|
||||
"not found. Unable to authenticate with the server"
|
||||
) from import_err
|
||||
|
||||
try:
|
||||
from fido2.pcsc import CtapPcscDevice
|
||||
|
||||
CTAP_PCSC_DEVICE_AVAILABLE = True
|
||||
except ModuleNotFoundError:
|
||||
CTAP_PCSC_DEVICE_AVAILABLE = False
|
||||
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLWebAuthnAuthPlugin"
|
||||
|
||||
|
||||
class ClientInteraction(UserInteraction):
|
||||
"""Provides user interaction to the Client."""
|
||||
|
||||
def __init__(self, callback: Optional[Callable] = None):
|
||||
self.callback = callback
|
||||
self.msg = (
|
||||
"Please insert FIDO device and perform gesture action for authentication "
|
||||
"to complete."
|
||||
)
|
||||
|
||||
def prompt_up(self) -> None:
|
||||
"""Prompt message for the user interaction with the FIDO device."""
|
||||
if self.callback is None:
|
||||
print(self.msg)
|
||||
else:
|
||||
self.callback(self.msg)
|
||||
|
||||
|
||||
class MySQLWebAuthnAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL WebAuthn authentication plugin."""
|
||||
|
||||
client: Optional[Fido2Client] = None
|
||||
callback: Optional[Callable] = None
|
||||
options: dict = {"rpId": None, "challenge": None, "allowCredentials": []}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_webauthn_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def get_assertion_response(
|
||||
self, credential_id: Optional[bytearray] = None
|
||||
) -> bytes:
|
||||
"""Get assertion from authenticator and return the response.
|
||||
|
||||
Args:
|
||||
credential_id (Optional[bytearray]): The credential ID.
|
||||
|
||||
Returns:
|
||||
bytearray: The response packet with the data from the assertion.
|
||||
"""
|
||||
if self.client is None:
|
||||
raise errors.InterfaceError("No WebAuthn client found")
|
||||
|
||||
if credential_id is not None:
|
||||
# If credential_id is not None, it's because the FIDO device does not
|
||||
# support resident keys and the credential_id was requested from the server
|
||||
self.options["allowCredentials"] = [
|
||||
{
|
||||
"id": credential_id,
|
||||
"type": "public-key",
|
||||
}
|
||||
]
|
||||
|
||||
# Get assertion from authenticator
|
||||
assertion = self.client.get_assertion(
|
||||
PublicKeyCredentialRequestOptions.from_dict(self.options)
|
||||
)
|
||||
number_of_assertions = len(assertion.get_assertions())
|
||||
client_data_json = b""
|
||||
|
||||
# Build response packet
|
||||
#
|
||||
# Format:
|
||||
# int<1> 0x02 (2) status tag
|
||||
# int<lenenc> number of assertions length encoded number of assertions
|
||||
# string authenticator data variable length raw binary string
|
||||
# string signed challenge variable length raw binary string
|
||||
# ...
|
||||
# ...
|
||||
# string authenticator data variable length raw binary string
|
||||
# string signed challenge variable length raw binary string
|
||||
# string ClientDataJSON variable length raw binary string
|
||||
packet = utils.lc_int(2)
|
||||
packet += utils.lc_int(number_of_assertions)
|
||||
|
||||
# Add authenticator data and signed challenge for each assertion
|
||||
for i in range(number_of_assertions):
|
||||
assertion_response = assertion.get_response(i)
|
||||
|
||||
# string<lenenc> authenticator_data
|
||||
authenticator_data = cbor_dump_bytes(assertion_response.authenticator_data)
|
||||
|
||||
# string<lenenc> signed_challenge
|
||||
signature = assertion_response.signature
|
||||
|
||||
packet += utils.lc_int(len(authenticator_data))
|
||||
packet += authenticator_data
|
||||
packet += utils.lc_int(len(signature))
|
||||
packet += signature
|
||||
|
||||
# string<lenenc> client_data_json
|
||||
client_data_json = assertion_response.client_data
|
||||
|
||||
packet += utils.lc_int(len(client_data_json))
|
||||
packet += client_data_json
|
||||
|
||||
logger.debug("WebAuthn - payload response packet: %s", packet)
|
||||
return packet
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Find authenticator device and check if supports resident keys.
|
||||
|
||||
It also creates a Fido2Client using the relying party ID from the server.
|
||||
|
||||
Raises:
|
||||
InterfaceError: When the FIDO device is not found.
|
||||
|
||||
Returns:
|
||||
bytes: 2 if the authenticator supports resident keys else 1.
|
||||
"""
|
||||
try:
|
||||
packets, capability = utils.read_int(auth_data, 1)
|
||||
challenge, rp_id = utils.read_lc_string_list(packets)
|
||||
self.options["challenge"] = challenge
|
||||
self.options["rpId"] = rp_id.decode()
|
||||
logger.debug("WebAuthn - capability: %d", capability)
|
||||
logger.debug("WebAuthn - challenge: %s", self.options["challenge"])
|
||||
logger.debug("WebAuthn - relying party id: %s", self.options["rpId"])
|
||||
except ValueError as err:
|
||||
raise errors.InterfaceError(
|
||||
"Unable to parse MySQL WebAuthn authentication data"
|
||||
) from err
|
||||
|
||||
# Locate a device
|
||||
device = next(CtapHidDevice.list_devices(), None)
|
||||
if device is not None:
|
||||
logger.debug("WebAuthn - Use USB HID channel")
|
||||
elif CTAP_PCSC_DEVICE_AVAILABLE:
|
||||
device = next(CtapPcscDevice.list_devices(), None)
|
||||
|
||||
if device is None:
|
||||
raise errors.InterfaceError("No FIDO device found")
|
||||
|
||||
# Set up a FIDO 2 client using the origin relying party id
|
||||
self.client = Fido2Client(
|
||||
device,
|
||||
f"https://{self.options['rpId']}",
|
||||
user_interaction=ClientInteraction(self.callback),
|
||||
)
|
||||
|
||||
if not self.client.info.options.get("rk"):
|
||||
logger.debug("WebAuthn - Authenticator doesn't support resident keys")
|
||||
return b"1"
|
||||
|
||||
logger.debug("WebAuthn - Authenticator with support for resident key found")
|
||||
return b"2"
|
||||
|
||||
async def auth_more_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth more data` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Authentication method data (from a packet representing
|
||||
an `auth more data` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
_, credential_id = utils.read_lc_string(auth_data)
|
||||
|
||||
response = self.get_assertion_response(credential_id)
|
||||
|
||||
logger.debug("WebAuthn - request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
pkt = bytes(await sock.read())
|
||||
logger.debug("WebAuthn - server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
webauth_callback = kwargs.get("webauthn_callback") or kwargs.get(
|
||||
"fido_callback"
|
||||
)
|
||||
self.callback = (
|
||||
utils.import_object(webauth_callback)
|
||||
if isinstance(webauth_callback, str)
|
||||
else webauth_callback
|
||||
)
|
||||
|
||||
response = self.auth_response(auth_data)
|
||||
credential_id = None
|
||||
|
||||
if response == b"1":
|
||||
# Authenticator doesn't support resident keys, request credential_id
|
||||
logger.debug("WebAuthn - request credential_id")
|
||||
await sock.write(utils.lc_int(int(response)))
|
||||
|
||||
# return a packet representing an `auth more data` response
|
||||
return bytes(await sock.read())
|
||||
|
||||
response = self.get_assertion_response(credential_id)
|
||||
|
||||
logger.debug("WebAuthn - request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
pkt = bytes(await sock.read())
|
||||
logger.debug("WebAuthn - server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,160 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Caching SHA2 Password Authentication Plugin."""
|
||||
|
||||
import struct
|
||||
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from mysql.connector.errors import InterfaceError
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLCachingSHA2PasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLCachingSHA2PasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL caching_sha2_password authentication plugin
|
||||
|
||||
Note that encrypting using RSA is not supported since the Python
|
||||
Standard Library does not provide this OpenSSL functionality.
|
||||
"""
|
||||
|
||||
perform_full_authentication: int = 4
|
||||
|
||||
def _scramble(self, auth_data: bytes) -> bytes:
|
||||
"""Return a scramble of the password using a Nonce sent by the
|
||||
server.
|
||||
|
||||
The scramble is of the form:
|
||||
XOR(SHA2(password), SHA2(SHA2(SHA2(password)), Nonce))
|
||||
"""
|
||||
if not auth_data:
|
||||
raise InterfaceError("Missing authentication data (seed)")
|
||||
|
||||
if not self._password:
|
||||
return b""
|
||||
|
||||
hash1 = sha256(self._password.encode()).digest()
|
||||
hash2 = sha256()
|
||||
hash2.update(sha256(hash1).digest())
|
||||
hash2.update(auth_data)
|
||||
hash2_digest = hash2.digest()
|
||||
xored = [h1 ^ h2 for (h1, h2) in zip(hash1, hash2_digest)]
|
||||
hash3 = struct.pack("32B", *xored)
|
||||
return hash3
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "caching_sha2_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Make the client's authorization response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
"""
|
||||
if not auth_data:
|
||||
return None
|
||||
if len(auth_data) > 1:
|
||||
return self._scramble(auth_data)
|
||||
if auth_data[0] == self.perform_full_authentication:
|
||||
# return password as clear text.
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
return None
|
||||
|
||||
async def auth_more_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth more data` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Authentication method data (from a packet representing
|
||||
an `auth more data` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response:
|
||||
await sock.write(response)
|
||||
|
||||
return bytes(await sock.read())
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
pkt = bytes(await sock.read())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,105 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Clear Password Authentication Plugin."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from mysql.connector import errors
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLClearPasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLClearPasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL Clear Password authentication plugin"""
|
||||
|
||||
def _prepare_password(self) -> bytes:
|
||||
"""Prepare and return password as as clear text.
|
||||
|
||||
Returns:
|
||||
bytes: Prepared password.
|
||||
"""
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "mysql_clear_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Return the prepared password to send to MySQL.
|
||||
|
||||
Raises:
|
||||
InterfaceError: When SSL is required by not enabled.
|
||||
|
||||
Returns:
|
||||
str: The prepared password.
|
||||
"""
|
||||
if self.requires_ssl and not self._ssl_enabled:
|
||||
raise errors.InterfaceError(f"{self.name} requires SSL")
|
||||
return self._prepare_password()
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
pkt = bytes(await sock.read())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,121 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Native Password Authentication Plugin."""
|
||||
|
||||
import struct
|
||||
|
||||
from hashlib import sha1
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from mysql.connector.errors import InterfaceError
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLNativePasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLNativePasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL Native Password authentication plugin"""
|
||||
|
||||
def _prepare_password(self, auth_data: bytes) -> bytes:
|
||||
"""Prepares and returns password as native MySQL 4.1+ password"""
|
||||
if not auth_data:
|
||||
raise InterfaceError("Missing authentication data (seed)")
|
||||
|
||||
if not self._password:
|
||||
return b""
|
||||
|
||||
hash4 = None
|
||||
try:
|
||||
hash1 = sha1(self._password.encode()).digest()
|
||||
hash2 = sha1(hash1).digest()
|
||||
hash3 = sha1(auth_data + hash2).digest()
|
||||
xored = [h1 ^ h3 for (h1, h3) in zip(hash1, hash3)]
|
||||
hash4 = struct.pack("20B", *xored)
|
||||
except (struct.error, TypeError) as err:
|
||||
raise InterfaceError(f"Failed scrambling password; {err}") from err
|
||||
|
||||
return hash4
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "mysql_native_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Make the client's authorization response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
"""
|
||||
return self._prepare_password(auth_data)
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
pkt = bytes(await sock.read())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,109 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""SHA256 Password Authentication Plugin."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from mysql.connector import errors
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLSHA256PasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLSHA256PasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL SHA256 authentication plugin
|
||||
|
||||
Note that encrypting using RSA is not supported since the Python
|
||||
Standard Library does not provide this OpenSSL functionality.
|
||||
"""
|
||||
|
||||
def _prepare_password(self) -> bytes:
|
||||
"""Prepare and return password as as clear text.
|
||||
|
||||
Returns:
|
||||
password (bytes): Prepared password.
|
||||
"""
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "sha256_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return True
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Return the prepared password to send to MySQL.
|
||||
|
||||
Raises:
|
||||
InterfaceError: When SSL is required by not enabled.
|
||||
|
||||
Returns:
|
||||
str: The prepared password.
|
||||
"""
|
||||
if self.requires_ssl and not self.ssl_enabled:
|
||||
raise errors.InterfaceError(f"{self.name} requires SSL")
|
||||
return self._prepare_password()
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
pkt = bytes(await sock.read())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,688 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Implementing pooling of connections to MySQL servers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, Any, Dict, NoReturn, Optional, Tuple, Type, Union
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from mysql.connector.constants import CNX_POOL_ARGS
|
||||
|
||||
try:
|
||||
import dns.asyncresolver
|
||||
import dns.exception
|
||||
except ImportError:
|
||||
HAVE_DNSPYTHON = False
|
||||
else:
|
||||
HAVE_DNSPYTHON = True
|
||||
|
||||
from ..errors import (
|
||||
Error,
|
||||
InterfaceError,
|
||||
NotSupportedError,
|
||||
PoolError,
|
||||
ProgrammingError,
|
||||
)
|
||||
from ..pooling import DEFAULT_CONFIGURATION, generate_pool_name, read_option_files
|
||||
from .connection import MySQLConnection
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
CONNECTION_POOL_LOCK = asyncio.Lock()
|
||||
CNX_POOL_MAXSIZE = 32
|
||||
CNX_POOL_MAXNAMESIZE = 64
|
||||
CNX_POOL_NAMEREGEX = re.compile(r"[^a-zA-Z0-9._:\-*$#]")
|
||||
MYSQL_CNX_CLASS: Union[type, Tuple[type, ...]] = (MySQLConnection,)
|
||||
|
||||
_CONNECTION_POOLS: Dict[str, MySQLConnectionPool] = {}
|
||||
|
||||
|
||||
async def _get_pooled_connection(**kwargs: Any) -> PooledMySQLConnection:
|
||||
"""Return a pooled MySQL connection."""
|
||||
# If no pool name specified, generate one
|
||||
pool_name = (
|
||||
kwargs["pool_name"] if "pool_name" in kwargs else generate_pool_name(**kwargs)
|
||||
)
|
||||
|
||||
# Setup the pool, ensuring only 1 thread can update at a time
|
||||
if pool_name not in _CONNECTION_POOLS:
|
||||
pool = MySQLConnectionPool(**kwargs)
|
||||
await pool.initialize_pool()
|
||||
|
||||
async with CONNECTION_POOL_LOCK:
|
||||
_CONNECTION_POOLS[pool_name] = pool
|
||||
elif isinstance(_CONNECTION_POOLS[pool_name], MySQLConnectionPool):
|
||||
# pool_size must be the same
|
||||
check_size = _CONNECTION_POOLS[pool_name].pool_size
|
||||
if "pool_size" in kwargs and kwargs["pool_size"] != check_size:
|
||||
raise PoolError("Size can not be changed for active pools.")
|
||||
|
||||
# Return pooled connection
|
||||
try:
|
||||
return await _CONNECTION_POOLS[pool_name].get_connection()
|
||||
except AttributeError:
|
||||
raise InterfaceError(
|
||||
f"Failed getting connection from pool '{pool_name}'"
|
||||
) from None
|
||||
|
||||
|
||||
async def _get_failover_connection(
|
||||
**kwargs: Any,
|
||||
) -> Union[PooledMySQLConnection, MySQLConnectionAbstract]:
|
||||
"""Return a MySQL connection and try to failover if needed.
|
||||
|
||||
An InterfaceError is raise when no MySQL is available. ValueError is
|
||||
raised when the failover server configuration contains an illegal
|
||||
connection argument. Supported arguments are user, password, host, port,
|
||||
unix_socket and database. ValueError is also raised when the failover
|
||||
argument was not provided.
|
||||
|
||||
Returns MySQLConnection instance.
|
||||
"""
|
||||
config = kwargs.copy()
|
||||
try:
|
||||
failover = config["failover"]
|
||||
except KeyError:
|
||||
raise ValueError("failover argument not provided") from None
|
||||
del config["failover"]
|
||||
|
||||
support_cnx_args = {
|
||||
"user",
|
||||
"password",
|
||||
"host",
|
||||
"port",
|
||||
"unix_socket",
|
||||
"database",
|
||||
"pool_name",
|
||||
"pool_size",
|
||||
"priority",
|
||||
}
|
||||
|
||||
# First check if we can add all use the configuration
|
||||
priority_count = 0
|
||||
for server in failover:
|
||||
diff = set(server.keys()) - support_cnx_args
|
||||
if diff:
|
||||
arg = "s" if len(diff) > 1 else ""
|
||||
lst = ", ".join(diff)
|
||||
raise ValueError(
|
||||
f"Unsupported connection argument {arg} in failover: {lst}"
|
||||
)
|
||||
if hasattr(server, "priority"):
|
||||
priority_count += 1
|
||||
|
||||
server["priority"] = server.get("priority", 100)
|
||||
if server["priority"] < 0 or server["priority"] > 100:
|
||||
raise InterfaceError(
|
||||
"Priority value should be in the range of 0 to 100, "
|
||||
f"got : {server['priority']}"
|
||||
)
|
||||
if not isinstance(server["priority"], int):
|
||||
raise InterfaceError(
|
||||
"Priority value should be an integer in the range of 0 to "
|
||||
f"100, got : {server['priority']}"
|
||||
)
|
||||
|
||||
if 0 < priority_count < len(failover):
|
||||
raise ProgrammingError(
|
||||
"You must either assign no priority to any "
|
||||
"of the routers or give a priority for "
|
||||
"every router"
|
||||
)
|
||||
|
||||
server_directory = {}
|
||||
server_priority_list = []
|
||||
for server in sorted(failover, key=lambda x: x["priority"], reverse=True):
|
||||
if server["priority"] not in server_directory:
|
||||
server_directory[server["priority"]] = [server]
|
||||
server_priority_list.append(server["priority"])
|
||||
else:
|
||||
server_directory[server["priority"]].append(server)
|
||||
|
||||
for priority in server_priority_list:
|
||||
failover_list = server_directory[priority]
|
||||
for _ in range(len(failover_list)):
|
||||
last = len(failover_list) - 1
|
||||
index = random.randint(0, last)
|
||||
server = failover_list.pop(index)
|
||||
new_config = config.copy()
|
||||
new_config.update(server)
|
||||
new_config.pop("priority", None)
|
||||
try:
|
||||
return await connect(**new_config)
|
||||
except Error:
|
||||
# If we failed to connect, we try the next server
|
||||
pass
|
||||
|
||||
raise InterfaceError("Unable to connect to any of the target hosts")
|
||||
|
||||
|
||||
async def connect(
|
||||
*args: Any, **kwargs: Any
|
||||
) -> Union[PooledMySQLConnection, MySQLConnectionAbstract]:
|
||||
"""Creates a MySQL connection object.
|
||||
|
||||
In its simpliest form, `connect()` will open a connection to a
|
||||
MySQL server and return a `MySQLConnectionAbstract` subclass
|
||||
object such as `MySQLConnection` or `CMySQLConnection`.
|
||||
|
||||
Args:
|
||||
*args: N/A.
|
||||
**kwargs: For a complete list of possible arguments, see [1]. If no arguments
|
||||
are given, it uses the already configured or default values.
|
||||
|
||||
Returns:
|
||||
A `MySQLConnectionAbstract` subclass instance (such as `MySQLConnection`)
|
||||
or a `PooledMySQLConnection` instance.
|
||||
|
||||
Raises:
|
||||
`mysql.connector.errors.NotSupportedError`: if pooled connection is not supported
|
||||
for the Python version and OS that are being used.
|
||||
|
||||
Examples:
|
||||
A connection with the MySQL server can be established using either the
|
||||
`mysql.connector.aio.connect()` method or a `MySQLConnectionAbstract` subclass:
|
||||
```
|
||||
>>> from mysql.connector.aio import MySQLConnection
|
||||
>>>
|
||||
>>> cnx1 = await mysql.connector.aio.connect(user='joe', database='test')
|
||||
>>> cnx2 = MySQLConnection(user='joe', database='test')
|
||||
>>>
|
||||
```
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
# DNS SRV
|
||||
dns_srv = kwargs.pop("dns_srv") if "dns_srv" in kwargs else False
|
||||
|
||||
if not isinstance(dns_srv, bool):
|
||||
raise InterfaceError("The value of 'dns-srv' must be a boolean")
|
||||
|
||||
if dns_srv:
|
||||
if not HAVE_DNSPYTHON:
|
||||
raise InterfaceError(
|
||||
"MySQL host configuration requested DNS "
|
||||
"SRV. This requires the Python dnspython "
|
||||
"module. Please refer to documentation"
|
||||
)
|
||||
if "unix_socket" in kwargs:
|
||||
raise InterfaceError(
|
||||
"Using Unix domain sockets with DNS SRV lookup is not allowed"
|
||||
)
|
||||
if "port" in kwargs:
|
||||
raise InterfaceError(
|
||||
"Specifying a port number with DNS SRV lookup is not allowed"
|
||||
)
|
||||
if "failover" in kwargs:
|
||||
raise InterfaceError(
|
||||
"Specifying multiple hostnames with DNS SRV look up is not allowed"
|
||||
)
|
||||
if "host" not in kwargs:
|
||||
kwargs["host"] = DEFAULT_CONFIGURATION["host"]
|
||||
|
||||
try:
|
||||
srv_records = await dns.asyncresolver.query(kwargs["host"], "SRV")
|
||||
except dns.exception.DNSException:
|
||||
raise InterfaceError(
|
||||
f"Unable to locate any hosts for '{kwargs['host']}'"
|
||||
) from None
|
||||
|
||||
failover = []
|
||||
for srv in srv_records:
|
||||
failover.append(
|
||||
{
|
||||
"host": srv.target.to_text(omit_final_dot=True),
|
||||
"port": srv.port,
|
||||
"priority": srv.priority,
|
||||
"weight": srv.weight,
|
||||
}
|
||||
)
|
||||
|
||||
failover.sort(key=lambda x: (x["priority"], -x["weight"]))
|
||||
kwargs["failover"] = [
|
||||
{"host": srv["host"], "port": srv["port"]} for srv in failover
|
||||
]
|
||||
|
||||
# Failover
|
||||
if "failover" in kwargs:
|
||||
return await _get_failover_connection(**kwargs)
|
||||
|
||||
# Pooled connections
|
||||
try:
|
||||
if any(key in kwargs for key in CNX_POOL_ARGS):
|
||||
return await _get_pooled_connection(**kwargs)
|
||||
except NameError:
|
||||
# No pooling
|
||||
pass
|
||||
|
||||
# Option files
|
||||
if "read_default_file" in kwargs:
|
||||
kwargs["option_files"] = kwargs["read_default_file"]
|
||||
kwargs.pop("read_default_file")
|
||||
|
||||
if "option_files" in kwargs:
|
||||
new_config = read_option_files(**kwargs)
|
||||
return await connect(**new_config)
|
||||
|
||||
if "use_pure" in kwargs:
|
||||
del kwargs["use_pure"] # Remove 'use_pure' from kwargs
|
||||
|
||||
cnx = MySQLConnection(*args, **kwargs)
|
||||
await cnx.connect()
|
||||
return cnx
|
||||
|
||||
|
||||
def _check_support() -> None:
|
||||
"""Raises error if the Python version and OS combination is not
|
||||
supported for Pooling."""
|
||||
py_version_info = sys.version_info
|
||||
if os.name == "nt" and py_version_info.major == 3 and py_version_info.minor <= 10:
|
||||
raise NotSupportedError(
|
||||
"Pooled connections are not supported for Python versions "
|
||||
"lower than 3.11 on Windows"
|
||||
)
|
||||
|
||||
|
||||
class PooledMySQLConnection:
|
||||
"""Class holding a MySQL Connection in a pool
|
||||
PooledMySQLConnection is used by MySQLConnectionPool to return an
|
||||
instance holding a MySQL connection. It works like a MySQLConnection
|
||||
except for methods like close() and config().
|
||||
The close()-method will add the connection back to the pool rather
|
||||
than disconnecting from the MySQL server.
|
||||
Configuring the connection have to be done through the MySQLConnectionPool
|
||||
method set_config(). Using config() on pooled connection will raise a
|
||||
PoolError.
|
||||
Attributes:
|
||||
pool_name (str): Returns the name of the connection pool to which the
|
||||
connection belongs.
|
||||
"""
|
||||
|
||||
def __init__(self, pool: MySQLConnectionPool, cnx: MySQLConnectionAbstract) -> None:
|
||||
"""Constructor.
|
||||
Args:
|
||||
pool: A `MySQLConnectionPool` instance.
|
||||
cnx: A `MySQLConnectionAbstract` subclass instance.
|
||||
"""
|
||||
_check_support()
|
||||
|
||||
if not isinstance(pool, MySQLConnectionPool):
|
||||
raise AttributeError("pool must be an instance of MySQLConnectionPool")
|
||||
if not isinstance(cnx, MYSQL_CNX_CLASS):
|
||||
raise AttributeError("cnx should be an instance of MySQLConnection")
|
||||
self._cnx_pool: MySQLConnectionPool = pool
|
||||
self._cnx: MySQLConnectionAbstract = cnx
|
||||
|
||||
async def __aenter__(self) -> PooledMySQLConnection:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: Type[BaseException],
|
||||
exc_value: BaseException,
|
||||
traceback: TracebackType,
|
||||
) -> None:
|
||||
await self.close()
|
||||
|
||||
def __getattr__(self, attr: Any) -> Any:
|
||||
"""Calls attributes of the MySQLConnection instance"""
|
||||
return getattr(self._cnx, attr)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Do not close, but adds connection back to pool.
|
||||
For a pooled connection, close() does not actually close it but returns it
|
||||
to the pool and makes it available for subsequent connection requests. If the
|
||||
pool configuration parameters are changed, a returned connection is closed
|
||||
and reopened with the new configuration before being returned from the pool
|
||||
again in response to a connection request.
|
||||
"""
|
||||
cnx = self._cnx
|
||||
try:
|
||||
if self._cnx_pool.can_reset_session and await cnx.is_connected():
|
||||
await cnx.reset_session()
|
||||
finally:
|
||||
await self._cnx_pool.add_connection(cnx)
|
||||
self._cnx = None
|
||||
|
||||
@staticmethod
|
||||
def config(**kwargs: Any) -> NoReturn:
|
||||
"""Configuration is done through the pool.
|
||||
For pooled connections, the `config()` method raises a `PoolError`
|
||||
exception. Configuration for pooled connections should be done
|
||||
using the pool object.
|
||||
"""
|
||||
raise PoolError(
|
||||
"Configuration for pooled connections should be done through the "
|
||||
"pool itself"
|
||||
)
|
||||
|
||||
@property
|
||||
def pool_name(self) -> str:
|
||||
"""Returns the name of the connection pool to which the connection belongs."""
|
||||
return self._cnx_pool.pool_name
|
||||
|
||||
|
||||
class MySQLConnectionPool:
|
||||
"""Class defining a pool of MySQL connections"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pool_size: int = 5,
|
||||
pool_name: Optional[str] = None,
|
||||
pool_reset_session: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Constructor.
|
||||
|
||||
Initialize a MySQL connection pool with a maximum number of
|
||||
connections set to `pool_size`.
|
||||
|
||||
NOTE: The coroutine `await cnxpool.initialize_pool(**dbconfig)` must be called
|
||||
after initializing this class to open up the pool of required number of database
|
||||
connections and making the instance of MySQLConnectionPool ready-to-use.
|
||||
Contents of `dbconfig` is described in [1].
|
||||
|
||||
Args:
|
||||
pool_name: The pool name. If this argument is not given, Connector/Python
|
||||
automatically generates the name, composed from whichever of
|
||||
the host, port, user, and database connection arguments are
|
||||
given in kwargs, in that order.
|
||||
pool_size: The pool size. If this argument is not given, the default is 5.
|
||||
pool_reset_session: Whether to reset session variables when the connection
|
||||
is returned to the pool.
|
||||
|
||||
Examples:
|
||||
```
|
||||
>>> dbconfig = {
|
||||
>>> "database": "test",
|
||||
>>> "user": "joe",
|
||||
>>> }
|
||||
>>> # initialize the MySQLConnectionPool instance
|
||||
>>> cnxpool = mysql.connector.aio.pooling.MySQLConnectionPool(
|
||||
>>> pool_name = "mypool",
|
||||
>>> pool_size = 3,
|
||||
>>> )
|
||||
>>> # Open up the pool of connections
|
||||
>>> await cnxpool.initialize_pool(**dbconfig)
|
||||
>>> # cnxpool is ready to be used ...
|
||||
```
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
_check_support()
|
||||
|
||||
self._pool_size: Optional[int] = None
|
||||
self._pool_name: Optional[str] = None
|
||||
self._reset_session: bool = pool_reset_session
|
||||
self._set_pool_size(pool_size)
|
||||
if pool_name:
|
||||
self._set_pool_name(pool_name)
|
||||
self._cnx_config: Dict[str, Any] = kwargs
|
||||
self._cnx_queue: asyncio.Queue[MySQLConnectionAbstract] = asyncio.Queue(
|
||||
self._pool_size
|
||||
)
|
||||
self._config_version: UUID = uuid4()
|
||||
|
||||
async def initialize_pool(self) -> None:
|
||||
"""Opens the connection pool and fill with MySQL database connections.
|
||||
|
||||
This coroutine must be called after initializing an instance of MySQLConnectionPool
|
||||
to make the build the pool of connections and make the instance ready to be used.
|
||||
|
||||
Args:
|
||||
**kwargs: Connection arguments, as described in [1].
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
if not self._pool_name:
|
||||
async with CONNECTION_POOL_LOCK:
|
||||
self._set_pool_name(generate_pool_name(**self._cnx_config))
|
||||
if self._cnx_config:
|
||||
await self.set_config(**self._cnx_config)
|
||||
cnt = 0
|
||||
while cnt < self._pool_size:
|
||||
await self.add_connection()
|
||||
cnt += 1
|
||||
|
||||
@property
|
||||
def pool_name(self) -> str:
|
||||
"""Returns the name of the connection pool."""
|
||||
return self._pool_name
|
||||
|
||||
@property
|
||||
def pool_size(self) -> int:
|
||||
"""Returns number of connections managed by the pool."""
|
||||
return self._pool_size
|
||||
|
||||
@property
|
||||
def can_reset_session(self) -> bool:
|
||||
"""Returns whether to reset session."""
|
||||
return self._reset_session
|
||||
|
||||
async def set_config(self, **kwargs: Any) -> None:
|
||||
"""Set the connection configuration for `MySQLConnectionAbstract` subclass instances.
|
||||
This method sets the configuration used for creating `MySQLConnectionAbstract`
|
||||
subclass instances such as `MySQLConnection`. See [1] for valid
|
||||
connection arguments.
|
||||
Args:
|
||||
**kwargs: Connection arguments - for a complete list of possible
|
||||
arguments, see [1].
|
||||
Raises:
|
||||
PoolError: When a connection argument is not valid, missing
|
||||
or not supported by `MySQLConnectionAbstract`.
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
if not kwargs:
|
||||
return
|
||||
|
||||
try:
|
||||
# test if we're able to connect using the provided connection options
|
||||
cnx = await connect(**kwargs)
|
||||
await cnx.disconnect()
|
||||
async with CONNECTION_POOL_LOCK:
|
||||
self._cnx_config = kwargs
|
||||
self._config_version = uuid4()
|
||||
except Exception as err:
|
||||
raise PoolError(f"Connection configuration not valid: {err}") from err
|
||||
|
||||
def _set_pool_size(self, pool_size: int) -> None:
|
||||
"""Set the size of the pool
|
||||
This method sets the size of the pool but it will not resize the pool.
|
||||
Raises an AttributeError when the pool_size is not valid. Invalid size
|
||||
is 0, negative or higher than pooling.CNX_POOL_MAXSIZE.
|
||||
"""
|
||||
if pool_size <= 0 or pool_size > CNX_POOL_MAXSIZE:
|
||||
raise AttributeError(
|
||||
"Pool size should be higher than 0 and lower or equal to "
|
||||
f"{CNX_POOL_MAXSIZE}"
|
||||
)
|
||||
self._pool_size = pool_size
|
||||
|
||||
def _set_pool_name(self, pool_name: str) -> None:
|
||||
r"""Set the name of the pool.
|
||||
This method checks the validity and sets the name of the pool.
|
||||
Raises an AttributeError when pool_name contains illegal characters
|
||||
([^a-zA-Z0-9._\-*$#]) or is longer than pooling.CNX_POOL_MAXNAMESIZE.
|
||||
"""
|
||||
if CNX_POOL_NAMEREGEX.search(pool_name):
|
||||
raise AttributeError(f"Pool name '{pool_name}' contains illegal characters")
|
||||
if len(pool_name) > CNX_POOL_MAXNAMESIZE:
|
||||
raise AttributeError(f"Pool name '{pool_name}' is too long")
|
||||
self._pool_name = pool_name
|
||||
|
||||
def _queue_connection(self, cnx: MySQLConnectionAbstract) -> None:
|
||||
"""Put connection back in the queue
|
||||
This method is putting a connection back in the queue. It will not
|
||||
acquire a lock as the methods using _queue_connection() will have it
|
||||
set.
|
||||
Raises `PoolError` on errors.
|
||||
"""
|
||||
if not isinstance(cnx, MYSQL_CNX_CLASS):
|
||||
raise PoolError(
|
||||
"Connection instance not subclass of MySQLConnectionAbstract"
|
||||
)
|
||||
|
||||
try:
|
||||
self._cnx_queue.put_nowait(cnx)
|
||||
except asyncio.QueueFull as err:
|
||||
raise PoolError("Failed adding connection; queue is full") from err
|
||||
|
||||
async def add_connection(
|
||||
self, cnx: Optional[MySQLConnectionAbstract] = None
|
||||
) -> None:
|
||||
"""Adds a connection to the pool.
|
||||
This method instantiates a `MySQLConnection` using the configuration
|
||||
passed when initializing the `MySQLConnectionPool` instance or using
|
||||
the `set_config()` method.
|
||||
If cnx is a `MySQLConnection` instance, it will be added to the
|
||||
queue.
|
||||
Args:
|
||||
cnx: The `MySQLConnectionAbstract` subclass object to be added to
|
||||
the pool. If this argument is missing (aka `None`), the pool
|
||||
creates a new connection and adds it.
|
||||
Raises:
|
||||
PoolError: When no configuration is set, when no more
|
||||
connection can be added (maximum reached) or when the connection
|
||||
can not be instantiated.
|
||||
"""
|
||||
if not self._cnx_config:
|
||||
raise PoolError("Connection configuration not available")
|
||||
|
||||
if self._cnx_queue.full():
|
||||
raise PoolError("Failed adding connection; queue is full")
|
||||
|
||||
if not cnx:
|
||||
cnx = await connect(**self._cnx_config)
|
||||
try:
|
||||
if (
|
||||
self._reset_session
|
||||
and self._cnx_config["compress"]
|
||||
and cnx.get_server_version() < (5, 7, 3)
|
||||
):
|
||||
raise NotSupportedError(
|
||||
"Pool reset session is not supported with "
|
||||
"compression for MySQL server version 5.7.2 "
|
||||
"or earlier"
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
cnx.pool_config_version = self._config_version
|
||||
else:
|
||||
if not isinstance(cnx, MYSQL_CNX_CLASS):
|
||||
raise PoolError(
|
||||
"Connection instance not subclass of MySQLConnectionAbstract"
|
||||
)
|
||||
|
||||
self._queue_connection(cnx)
|
||||
|
||||
async def get_connection(self) -> PooledMySQLConnection:
|
||||
"""Gets a connection from the pool.
|
||||
This method returns an PooledMySQLConnection instance which
|
||||
has a reference to the pool that created it, and the next available
|
||||
MySQL connection.
|
||||
When the MySQL connection is not connect, a reconnect is attempted.
|
||||
Returns:
|
||||
A `PooledMySQLConnection` instance.
|
||||
Raises:
|
||||
PoolError: On errors.
|
||||
"""
|
||||
|
||||
try:
|
||||
cnx = self._cnx_queue.get_nowait()
|
||||
except asyncio.QueueEmpty as err:
|
||||
raise PoolError("Failed getting connection; pool exhausted") from err
|
||||
|
||||
if (
|
||||
not await cnx.is_connected()
|
||||
or self._config_version != cnx.pool_config_version
|
||||
):
|
||||
try:
|
||||
cnx._set_connection_options(**self._cnx_config)
|
||||
await cnx.reconnect()
|
||||
except InterfaceError:
|
||||
self._queue_connection(cnx)
|
||||
raise
|
||||
cnx.pool_config_version = self._config_version
|
||||
|
||||
return PooledMySQLConnection(self, cnx)
|
||||
|
||||
async def _remove_connections(self) -> int:
|
||||
"""Close all connections
|
||||
This method closes all connections and returns the number of connections it closed.
|
||||
This method should be used internally while cleaning-up the connection pool by closing each
|
||||
and every active connection objects currently residing in the pool.
|
||||
|
||||
Returns:
|
||||
An integer defining the number of open connections closed during clean-up.
|
||||
Raises:
|
||||
PoolError: On errors while fetching connections from the pool or while disconnecting
|
||||
an open connection.
|
||||
"""
|
||||
cnt = 0
|
||||
cnxq = self._cnx_queue
|
||||
while cnxq.qsize():
|
||||
try:
|
||||
cnx = cnxq.get_nowait()
|
||||
await cnx.disconnect()
|
||||
cnt += 1
|
||||
except asyncio.QueueEmpty:
|
||||
return cnt
|
||||
except PoolError:
|
||||
raise
|
||||
except Error:
|
||||
# Any other error when closing means connection is closed
|
||||
pass
|
||||
|
||||
return cnt
|
||||
|
||||
async def close_pool(self) -> int:
|
||||
"""Cleans up the connection pool
|
||||
This public method gracefully shuts down the connection pool by disconnecting all of the
|
||||
open connections currently active in the pool.
|
||||
|
||||
Returns:
|
||||
An integer defining the number of open connections closed while shutting down the pool.
|
||||
Raises:
|
||||
PoolError: On errors while fetching connections from the pool or while disconnecting
|
||||
an open connection.
|
||||
"""
|
||||
return await self._remove_connections()
|
||||
@@ -0,0 +1,325 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Implements the MySQL Client/Server protocol."""
|
||||
|
||||
__all__ = ["MySQLProtocol"]
|
||||
|
||||
import struct
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..constants import ClientFlag, ServerCmd
|
||||
from ..errors import InterfaceError, ProgrammingError, get_exception
|
||||
from ..logger import logger
|
||||
from ..protocol import (
|
||||
DEFAULT_CHARSET_ID,
|
||||
DEFAULT_MAX_ALLOWED_PACKET,
|
||||
MySQLProtocol as _MySQLProtocol,
|
||||
)
|
||||
from ..types import BinaryProtocolType, DescriptionType, EofPacketType, HandShakeType
|
||||
from ..utils import lc_int, read_lc_string_list
|
||||
from .network import MySQLSocket
|
||||
from .plugins import MySQLAuthPlugin, get_auth_plugin
|
||||
from .plugins.caching_sha2_password import MySQLCachingSHA2PasswordAuthPlugin
|
||||
|
||||
|
||||
class MySQLProtocol(_MySQLProtocol):
|
||||
"""Implements MySQL client/server protocol.
|
||||
|
||||
Create and parses MySQL packets.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def auth_plugin_first_response( # type: ignore[override]
|
||||
auth_data: bytes,
|
||||
username: str,
|
||||
password: str,
|
||||
auth_plugin: str,
|
||||
auth_plugin_class: Optional[str] = None,
|
||||
ssl_enabled: bool = False,
|
||||
plugin_config: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[bytes, MySQLAuthPlugin]:
|
||||
"""Prepare the first authentication response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data from initial handshake.
|
||||
username: Account's username.
|
||||
password: Account's password.
|
||||
client_flags: Integer representing client capabilities flags.
|
||||
auth_plugin: Authorization plugin name.
|
||||
auth_plugin_class: Authorization plugin class (has higher precedence
|
||||
than the authorization plugin name).
|
||||
ssl_enabled: Whether SSL is enabled or not.
|
||||
plugin_config: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override
|
||||
the ones defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
auth_response: Authorization plugin response.
|
||||
auth_strategy: Authorization plugin instance created based
|
||||
on the provided `auth_plugin` and `auth_plugin_class`
|
||||
parameters.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If authentication fails or when got a NULL auth response.
|
||||
"""
|
||||
if not password and auth_plugin == "":
|
||||
# return auth response and an arbitrary auth strategy
|
||||
return b"\x00", MySQLCachingSHA2PasswordAuthPlugin(
|
||||
username, password, ssl_enabled=ssl_enabled
|
||||
)
|
||||
|
||||
if plugin_config is None:
|
||||
plugin_config = {}
|
||||
|
||||
try:
|
||||
auth_strategy = get_auth_plugin(auth_plugin, auth_plugin_class)(
|
||||
username, password, ssl_enabled=ssl_enabled
|
||||
)
|
||||
auth_response = auth_strategy.auth_response(auth_data, **plugin_config)
|
||||
except (TypeError, InterfaceError) as err:
|
||||
raise InterfaceError(f"Failed authentication: {err}") from err
|
||||
|
||||
if auth_response is None:
|
||||
raise InterfaceError(
|
||||
"Got NULL auth response while authenticating with "
|
||||
f"plugin {auth_strategy.name}"
|
||||
)
|
||||
|
||||
auth_response = lc_int(len(auth_response)) + auth_response
|
||||
|
||||
return auth_response, auth_strategy
|
||||
|
||||
@staticmethod
|
||||
def make_auth( # type: ignore[override]
|
||||
handshake: HandShakeType,
|
||||
username: str,
|
||||
password: str,
|
||||
database: Optional[str] = None,
|
||||
charset: int = DEFAULT_CHARSET_ID,
|
||||
client_flags: int = 0,
|
||||
max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET,
|
||||
auth_plugin: Optional[str] = None,
|
||||
auth_plugin_class: Optional[str] = None,
|
||||
conn_attrs: Optional[Dict[str, str]] = None,
|
||||
is_change_user_request: bool = False,
|
||||
ssl_enabled: bool = False,
|
||||
plugin_config: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[bytes, MySQLAuthPlugin]:
|
||||
"""Make a MySQL Authentication packet.
|
||||
|
||||
Args:
|
||||
handshake: Initial handshake.
|
||||
username: Account's username.
|
||||
password: Account's password.
|
||||
database: Initial database name for the connection
|
||||
charset: Client charset (see [2]), only the lower 8-bits.
|
||||
client_flags: Integer representing client capabilities flags.
|
||||
max_allowed_packet: Maximum packet size.
|
||||
auth_plugin: Authorization plugin name.
|
||||
auth_plugin_class: Authorization plugin class (has higher precedence
|
||||
than the authorization plugin name).
|
||||
conn_attrs: Connection attributes.
|
||||
is_change_user_request: Whether is a `change user request` operation or not.
|
||||
ssl_enabled: Whether SSL is enabled or not.
|
||||
plugin_config: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override
|
||||
the one defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
handshake_response: Handshake response as per [1].
|
||||
auth_strategy: Authorization plugin instance created based
|
||||
on the provided `auth_plugin` and `auth_plugin_class`.
|
||||
|
||||
Raises:
|
||||
ProgrammingError: Handshake misses authentication info.
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\
|
||||
page_protocol_connection_phase_packets_protocol_handshake_response.html
|
||||
|
||||
[2]: https://dev.mysql.com/doc/dev/mysql-server/latest/\
|
||||
page_protocol_basic_character_set.html#a_protocol_character_set
|
||||
"""
|
||||
b_username = username.encode()
|
||||
response_payload = []
|
||||
|
||||
if is_change_user_request:
|
||||
logger.debug("Got a `change user` request")
|
||||
|
||||
logger.debug("Starting authorization phase")
|
||||
if handshake is None:
|
||||
raise ProgrammingError("Got a NULL handshake") from None
|
||||
|
||||
if handshake.get("auth_data") is None:
|
||||
raise ProgrammingError("Handshake misses authentication info") from None
|
||||
|
||||
try:
|
||||
auth_plugin = auth_plugin or handshake["auth_plugin"] # type: ignore[assignment]
|
||||
except (TypeError, KeyError) as err:
|
||||
raise ProgrammingError(
|
||||
f"Handshake misses authentication plugin info ({err})"
|
||||
) from None
|
||||
|
||||
logger.debug("The provided initial strategy is %s", auth_plugin)
|
||||
|
||||
if is_change_user_request:
|
||||
response_payload.append(
|
||||
struct.pack(
|
||||
f"<B{len(b_username)}sx",
|
||||
ServerCmd.CHANGE_USER,
|
||||
b_username,
|
||||
)
|
||||
)
|
||||
else:
|
||||
filler = "x" * 23
|
||||
response_payload.append(
|
||||
struct.pack(
|
||||
f"<IIB{filler}{len(b_username)}sx",
|
||||
client_flags,
|
||||
max_allowed_packet,
|
||||
charset,
|
||||
b_username,
|
||||
)
|
||||
)
|
||||
|
||||
# auth plugin response
|
||||
auth_response, auth_strategy = MySQLProtocol.auth_plugin_first_response(
|
||||
auth_data=handshake["auth_data"], # type: ignore[arg-type]
|
||||
username=username,
|
||||
password=password,
|
||||
auth_plugin=auth_plugin,
|
||||
auth_plugin_class=auth_plugin_class,
|
||||
ssl_enabled=ssl_enabled,
|
||||
plugin_config=plugin_config,
|
||||
)
|
||||
response_payload.append(auth_response)
|
||||
|
||||
# database name
|
||||
response_payload.append(MySQLProtocol.connect_with_db(client_flags, database))
|
||||
|
||||
# charset
|
||||
if is_change_user_request:
|
||||
response_payload.append(struct.pack("<H", charset))
|
||||
|
||||
# plugin name
|
||||
if client_flags & ClientFlag.PLUGIN_AUTH:
|
||||
response_payload.append(auth_plugin.encode() + b"\x00")
|
||||
|
||||
# connection attributes
|
||||
if (client_flags & ClientFlag.CONNECT_ARGS) and conn_attrs is not None:
|
||||
response_payload.append(MySQLProtocol.make_conn_attrs(conn_attrs))
|
||||
|
||||
return b"".join(response_payload), auth_strategy
|
||||
|
||||
# pylint: disable=invalid-overridden-method
|
||||
async def read_binary_result( # type: ignore[override]
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
columns: List[DescriptionType],
|
||||
count: int = 1,
|
||||
charset: str = "utf-8",
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> Tuple[
|
||||
List[Tuple[BinaryProtocolType, ...]],
|
||||
Optional[EofPacketType],
|
||||
]:
|
||||
"""Read MySQL binary protocol result.
|
||||
|
||||
Reads all or given number of binary resultset rows from the socket.
|
||||
"""
|
||||
rows = []
|
||||
eof = None
|
||||
values = None
|
||||
i = 0
|
||||
while True:
|
||||
if eof or i == count:
|
||||
break
|
||||
packet = await sock.read(read_timeout)
|
||||
if packet[4] == 254:
|
||||
eof = self.parse_eof(packet)
|
||||
values = None
|
||||
elif packet[4] == 0:
|
||||
eof = None
|
||||
values = self._parse_binary_values(columns, packet[5:], charset)
|
||||
if eof is None and values is not None:
|
||||
rows.append(values)
|
||||
elif eof is None and values is None:
|
||||
raise get_exception(packet)
|
||||
i += 1
|
||||
return (rows, eof)
|
||||
|
||||
# pylint: disable=invalid-overridden-method
|
||||
async def read_text_result( # type: ignore[override]
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
version: Tuple[int, ...],
|
||||
count: int = 1,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> Tuple[
|
||||
List[Tuple[Optional[bytes], ...]],
|
||||
Optional[EofPacketType],
|
||||
]:
|
||||
"""Read MySQL text result.
|
||||
|
||||
Reads all or given number of rows from the socket.
|
||||
|
||||
Returns a tuple with 2 elements: a list with all rows and
|
||||
the EOF packet.
|
||||
"""
|
||||
# Keep unused 'version' for API backward compatibility
|
||||
_ = version
|
||||
rows = []
|
||||
eof = None
|
||||
rowdata = None
|
||||
i = 0
|
||||
while True:
|
||||
if eof or i == count:
|
||||
break
|
||||
packet = await sock.read(read_timeout)
|
||||
if packet.startswith(b"\xff\xff\xff"):
|
||||
datas = [packet[4:]]
|
||||
packet = await sock.read(read_timeout)
|
||||
while packet.startswith(b"\xff\xff\xff"):
|
||||
datas.append(packet[4:])
|
||||
packet = await sock.read(read_timeout)
|
||||
datas.append(packet[4:])
|
||||
rowdata = read_lc_string_list(b"".join(datas))
|
||||
elif packet[4] == 254 and packet[0] < 7:
|
||||
eof = self.parse_eof(packet)
|
||||
rowdata = None
|
||||
else:
|
||||
eof = None
|
||||
rowdata = read_lc_string_list(bytes(packet[4:]))
|
||||
if eof is None and rowdata is not None:
|
||||
rows.append(rowdata)
|
||||
elif eof is None and rowdata is None:
|
||||
raise get_exception(packet)
|
||||
i += 1
|
||||
return rows, eof
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
@@ -0,0 +1,155 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="attr-defined"
|
||||
# pylint: disable=protected-access
|
||||
|
||||
"""Utilities."""
|
||||
|
||||
__all__ = ["to_thread", "open_connection"]
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import functools
|
||||
|
||||
try:
|
||||
import ssl
|
||||
except ImportError:
|
||||
ssl = None
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mysql.connector.aio.abstracts import MySQLConnectionAbstract
|
||||
|
||||
__all__.append("StreamWriter")
|
||||
|
||||
|
||||
class StreamReaderProtocol(asyncio.StreamReaderProtocol):
|
||||
"""Extends asyncio.streams.StreamReaderProtocol for adding start_tls().
|
||||
|
||||
The ``start_tls()`` is based on ``asyncio.streams.StreamWriter`` introduced
|
||||
in Python 3.11. It provides the same functionality for older Python versions.
|
||||
"""
|
||||
|
||||
def _replace_writer(self, writer: asyncio.StreamWriter) -> None:
|
||||
"""Replace stream writer.
|
||||
|
||||
Args:
|
||||
writer: Stream Writer.
|
||||
"""
|
||||
transport = writer.transport
|
||||
self._stream_writer = writer
|
||||
self._transport = transport
|
||||
self._over_ssl = transport.get_extra_info("sslcontext") is not None
|
||||
|
||||
|
||||
class StreamWriter(asyncio.streams.StreamWriter):
|
||||
"""Extends asyncio.streams.StreamWriter for adding start_tls().
|
||||
|
||||
The ``start_tls()`` is based on ``asyncio.streams.StreamWriter`` introduced
|
||||
in Python 3.11. It provides the same functionality for older Python versions.
|
||||
"""
|
||||
|
||||
async def start_tls(
|
||||
self,
|
||||
ssl_context: ssl.SSLContext,
|
||||
*,
|
||||
server_hostname: str = None,
|
||||
ssl_handshake_timeout: int = None,
|
||||
) -> None:
|
||||
"""Upgrade an existing stream-based connection to TLS.
|
||||
|
||||
Args:
|
||||
ssl_context: Configured SSL context.
|
||||
server_hostname: Server host name.
|
||||
ssl_handshake_timeout: SSL handshake timeout.
|
||||
"""
|
||||
server_side = self._protocol._client_connected_cb is not None
|
||||
protocol = self._protocol
|
||||
await self.drain()
|
||||
new_transport = await self._loop.start_tls(
|
||||
# pylint: disable=access-member-before-definition
|
||||
self._transport, # type: ignore[has-type]
|
||||
protocol,
|
||||
ssl_context,
|
||||
server_side=server_side,
|
||||
server_hostname=server_hostname,
|
||||
ssl_handshake_timeout=ssl_handshake_timeout,
|
||||
)
|
||||
self._transport = ( # pylint: disable=attribute-defined-outside-init
|
||||
new_transport
|
||||
)
|
||||
protocol._replace_writer(self)
|
||||
|
||||
|
||||
async def open_connection(
|
||||
host: str = None, port: int = None, *, limit: int = 2**16, **kwds: Any
|
||||
) -> Tuple[asyncio.StreamReader, StreamWriter]:
|
||||
"""A wrapper for create_connection() returning a (reader, writer) pair.
|
||||
|
||||
This function is based on ``asyncio.streams.open_connection`` and adds a custom
|
||||
stream reader.
|
||||
|
||||
MySQL expects TLS negotiation to happen in the middle of a TCP connection, not at
|
||||
the start.
|
||||
This function in conjunction with ``_StreamReaderProtocol`` and ``_StreamWriter``
|
||||
allows the TLS negotiation on an existing connection.
|
||||
|
||||
Args:
|
||||
host: Server host name.
|
||||
port: Server port.
|
||||
limit: The buffer size limit used by the returned ``StreamReader`` instance.
|
||||
By default the limit is set to 64 KiB.
|
||||
|
||||
Returns:
|
||||
tuple: Returns a pair of reader and writer objects that are instances of
|
||||
``StreamReader`` and ``StreamWriter`` classes.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
reader = asyncio.streams.StreamReader(limit=limit, loop=loop)
|
||||
protocol = StreamReaderProtocol(reader, loop=loop)
|
||||
transport, _ = await loop.create_connection(lambda: protocol, host, port, **kwds)
|
||||
writer = StreamWriter(transport, protocol, reader, loop)
|
||||
return reader, writer
|
||||
|
||||
|
||||
async def to_thread(func: Callable, *args: Any, **kwargs: Any) -> asyncio.Future:
|
||||
"""Asynchronously run function ``func`` in a separate thread.
|
||||
|
||||
This function is based on ``asyncio.to_thread()`` introduced in Python 3.9, which
|
||||
provides the same functionality for older Python versions.
|
||||
|
||||
Returns:
|
||||
coroutine: A coroutine that can be awaited to get the eventual result of
|
||||
``func``.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
ctx = contextvars.copy_context()
|
||||
func_call = functools.partial(ctx.run, func, *args, **kwargs)
|
||||
return await loop.run_in_executor(None, func_call)
|
||||
@@ -0,0 +1,385 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Implementing support for MySQL Authentication Plugins"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from .errors import InterfaceError, NotSupportedError, get_exception
|
||||
from .logger import logger
|
||||
from .plugins import MySQLAuthPlugin, get_auth_plugin
|
||||
from .protocol import (
|
||||
AUTH_SWITCH_STATUS,
|
||||
DEFAULT_CHARSET_ID,
|
||||
DEFAULT_MAX_ALLOWED_PACKET,
|
||||
ERR_STATUS,
|
||||
EXCHANGE_FURTHER_STATUS,
|
||||
MFA_STATUS,
|
||||
OK_STATUS,
|
||||
MySQLProtocol,
|
||||
)
|
||||
from .types import HandShakeType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .network import MySQLSocket
|
||||
|
||||
|
||||
class MySQLAuthenticator:
|
||||
"""Implements the authentication phase."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Constructor."""
|
||||
self._username: str = ""
|
||||
self._passwords: Dict[int, str] = {}
|
||||
self._plugin_config: Dict[str, Any] = {}
|
||||
self._ssl_enabled: bool = False
|
||||
self._auth_strategy: Optional[MySQLAuthPlugin] = None
|
||||
self._auth_plugin_class: Optional[str] = None
|
||||
|
||||
@property
|
||||
def ssl_enabled(self) -> bool:
|
||||
"""Signals whether or not SSL is enabled."""
|
||||
return self._ssl_enabled
|
||||
|
||||
@property
|
||||
def plugin_config(self) -> Dict[str, Any]:
|
||||
"""Custom arguments that are being provided to the authentication plugin when called.
|
||||
|
||||
The parameters defined here will override the ones defined in the
|
||||
auth plugin itself.
|
||||
|
||||
The plugin config is a read-only property - the plugin configuration
|
||||
provided when invoking `authenticate()` is recorded and can be queried
|
||||
by accessing this property.
|
||||
|
||||
Returns:
|
||||
dict: The latest plugin configuration provided when invoking
|
||||
`authenticate()`.
|
||||
"""
|
||||
return self._plugin_config
|
||||
|
||||
def update_plugin_config(self, config: Dict[str, Any]) -> None:
|
||||
"""Update the 'plugin_config' instance variable"""
|
||||
self._plugin_config.update(config)
|
||||
|
||||
def setup_ssl(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
host: str,
|
||||
ssl_options: Optional[Dict[str, Any]],
|
||||
charset: int = DEFAULT_CHARSET_ID,
|
||||
client_flags: int = 0,
|
||||
max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET,
|
||||
) -> bytes:
|
||||
"""Sets up an SSL communication channel.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
host: Server host name.
|
||||
ssl_options: SSL and TLS connection options (see
|
||||
`network.MySQLSocket.build_ssl_context`).
|
||||
charset: Client charset (see [1]), only the lower 8-bits.
|
||||
client_flags: Integer representing client capabilities flags.
|
||||
max_allowed_packet: Maximum packet size.
|
||||
|
||||
Returns:
|
||||
ssl_request_payload: Payload used to carry out SSL authentication.
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\
|
||||
page_protocol_basic_character_set.html#a_protocol_character_set
|
||||
"""
|
||||
if ssl_options is None:
|
||||
ssl_options = {}
|
||||
|
||||
# SSL connection request packet
|
||||
ssl_request_payload = MySQLProtocol.make_auth_ssl(
|
||||
charset=charset,
|
||||
client_flags=client_flags,
|
||||
max_allowed_packet=max_allowed_packet,
|
||||
)
|
||||
sock.send(ssl_request_payload)
|
||||
|
||||
logger.debug("Building SSL context")
|
||||
ssl_context = sock.build_ssl_context(
|
||||
ssl_ca=ssl_options.get("ca"),
|
||||
ssl_cert=ssl_options.get("cert"),
|
||||
ssl_key=ssl_options.get("key"),
|
||||
ssl_verify_cert=ssl_options.get("verify_cert", False),
|
||||
ssl_verify_identity=ssl_options.get("verify_identity", False),
|
||||
tls_versions=ssl_options.get("tls_versions"),
|
||||
tls_cipher_suites=ssl_options.get("tls_ciphersuites"),
|
||||
)
|
||||
|
||||
logger.debug("Switching to SSL")
|
||||
sock.switch_to_ssl(ssl_context, host)
|
||||
|
||||
logger.debug("SSL has been enabled")
|
||||
self._ssl_enabled = True
|
||||
|
||||
return ssl_request_payload
|
||||
|
||||
def _switch_auth_strategy(
|
||||
self,
|
||||
new_strategy_name: str,
|
||||
strategy_class: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password_factor: int = 1,
|
||||
) -> None:
|
||||
"""Switches the authorization plugin.
|
||||
|
||||
Args:
|
||||
new_strategy_name: New authorization plugin name to switch to.
|
||||
strategy_class: New authorization plugin class to switch to
|
||||
(has higher precedence than the authorization plugin name).
|
||||
username: Username to be used - if not defined, the username
|
||||
provided when `authentication()` was invoked is used.
|
||||
password_factor: Up to three levels of authentication (MFA) are allowed,
|
||||
hence you can choose the password corresponding to the 1st,
|
||||
2nd, or 3rd factor - 1st is the default.
|
||||
"""
|
||||
if username is None:
|
||||
username = self._username
|
||||
|
||||
if strategy_class is None:
|
||||
strategy_class = self._auth_plugin_class
|
||||
|
||||
logger.debug("Switching to strategy %s", new_strategy_name)
|
||||
self._auth_strategy = get_auth_plugin(
|
||||
plugin_name=new_strategy_name, auth_plugin_class=strategy_class
|
||||
)(
|
||||
username,
|
||||
self._passwords.get(password_factor, ""),
|
||||
ssl_enabled=self.ssl_enabled,
|
||||
)
|
||||
|
||||
def _mfa_n_factor(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
pkt: bytes,
|
||||
) -> Optional[bytes]:
|
||||
"""Handles MFA (Multi-Factor Authentication) response.
|
||||
|
||||
Up to three levels of authentication (MFA) are allowed.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
pkt: MFA response.
|
||||
|
||||
Returns:
|
||||
ok_packet: If last server's response is an OK packet.
|
||||
None: If last server's response isn't an OK packet and no ERROR was raised.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If got an invalid N factor.
|
||||
errors.ErrorTypes: If got an ERROR response.
|
||||
"""
|
||||
n_factor = 2
|
||||
while pkt[4] == MFA_STATUS:
|
||||
if n_factor not in self._passwords:
|
||||
raise InterfaceError(
|
||||
"Failed Multi Factor Authentication (invalid N factor)"
|
||||
)
|
||||
|
||||
new_strategy_name, auth_data = MySQLProtocol.parse_auth_next_factor(pkt)
|
||||
self._switch_auth_strategy(new_strategy_name, password_factor=n_factor)
|
||||
logger.debug("MFA %i factor %s", n_factor, self._auth_strategy.name)
|
||||
|
||||
pkt = self._auth_strategy.auth_switch_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == EXCHANGE_FURTHER_STATUS:
|
||||
auth_data = MySQLProtocol.parse_auth_more_data(pkt)
|
||||
pkt = self._auth_strategy.auth_more_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == OK_STATUS:
|
||||
logger.debug("MFA completed succesfully")
|
||||
return pkt
|
||||
|
||||
if pkt[4] == ERR_STATUS:
|
||||
raise get_exception(pkt)
|
||||
|
||||
n_factor += 1
|
||||
|
||||
logger.warning("MFA terminated with a no ok packet")
|
||||
return None
|
||||
|
||||
def _handle_server_response(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
pkt: bytes,
|
||||
) -> Optional[bytes]:
|
||||
"""Handles server's response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
pkt: Server's response after completing the `HandShakeResponse`.
|
||||
|
||||
Returns:
|
||||
ok_packet: If last server's response is an OK packet.
|
||||
None: If last server's response isn't an OK packet and no ERROR was raised.
|
||||
|
||||
Raises:
|
||||
errors.ErrorTypes: If got an ERROR response.
|
||||
NotSupportedError: If got Authentication with old (insecure) passwords.
|
||||
"""
|
||||
if pkt[4] == AUTH_SWITCH_STATUS and len(pkt) == 5:
|
||||
raise NotSupportedError(
|
||||
"Authentication with old (insecure) passwords "
|
||||
"is not supported. For more information, lookup "
|
||||
"Password Hashing in the latest MySQL manual"
|
||||
)
|
||||
|
||||
if pkt[4] == AUTH_SWITCH_STATUS:
|
||||
logger.debug("Server's response is an auth switch request")
|
||||
new_strategy_name, auth_data = MySQLProtocol.parse_auth_switch_request(pkt)
|
||||
self._switch_auth_strategy(new_strategy_name)
|
||||
pkt = self._auth_strategy.auth_switch_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == EXCHANGE_FURTHER_STATUS:
|
||||
logger.debug("Exchanging further packets")
|
||||
auth_data = MySQLProtocol.parse_auth_more_data(pkt)
|
||||
pkt = self._auth_strategy.auth_more_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == OK_STATUS:
|
||||
logger.debug("%s completed succesfully", self._auth_strategy.name)
|
||||
return pkt
|
||||
|
||||
if pkt[4] == MFA_STATUS:
|
||||
logger.debug("Starting multi-factor authentication")
|
||||
logger.debug("MFA 1 factor %s", self._auth_strategy.name)
|
||||
return self._mfa_n_factor(sock, pkt)
|
||||
|
||||
if pkt[4] == ERR_STATUS:
|
||||
raise get_exception(pkt)
|
||||
|
||||
return None
|
||||
|
||||
def authenticate(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
handshake: HandShakeType,
|
||||
username: str = "",
|
||||
password1: str = "",
|
||||
password2: str = "",
|
||||
password3: str = "",
|
||||
database: Optional[str] = None,
|
||||
charset: int = DEFAULT_CHARSET_ID,
|
||||
client_flags: int = 0,
|
||||
max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET,
|
||||
auth_plugin: Optional[str] = None,
|
||||
auth_plugin_class: Optional[str] = None,
|
||||
conn_attrs: Optional[Dict[str, str]] = None,
|
||||
is_change_user_request: bool = False,
|
||||
read_timeout: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> bytes:
|
||||
"""Performs the authentication phase.
|
||||
|
||||
During re-authentication you must set `is_change_user_request` to True.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
handshake: Initial handshake.
|
||||
username: Account's username.
|
||||
password1: Account's password factor 1.
|
||||
password2: Account's password factor 2.
|
||||
password3: Account's password factor 3.
|
||||
database: Initial database name for the connection.
|
||||
charset: Client charset (see [1]), only the lower 8-bits.
|
||||
client_flags: Integer representing client capabilities flags.
|
||||
max_allowed_packet: Maximum packet size.
|
||||
auth_plugin: Authorization plugin name.
|
||||
auth_plugin_class: Authorization plugin class (has higher precedence
|
||||
than the authorization plugin name).
|
||||
conn_attrs: Connection attributes.
|
||||
is_change_user_request: Whether is a `change user request` operation or not.
|
||||
read_timeout: Timeout in seconds upto which the connector should wait for
|
||||
the server to reply back before raising an ReadTimeoutError.
|
||||
write_timeout: Timeout in seconds upto which the connector should spend to
|
||||
send data to the server before raising an WriteTimeoutError.
|
||||
Returns:
|
||||
ok_packet: OK packet.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If OK packet is NULL.
|
||||
ReadTimeoutError: If the time taken for the server to reply back exceeds
|
||||
'read_timeout' (if set).
|
||||
WriteTimeoutError: If the time taken to send data packets to the server
|
||||
exceeds 'write_timeout' (if set).
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\
|
||||
page_protocol_basic_character_set.html#a_protocol_character_set
|
||||
"""
|
||||
# update credentials, plugin config and plugin class
|
||||
self._username = username
|
||||
self._passwords = {1: password1, 2: password2, 3: password3}
|
||||
self._auth_plugin_class = auth_plugin_class
|
||||
|
||||
# client's handshake response
|
||||
response_payload, self._auth_strategy = MySQLProtocol.make_auth(
|
||||
handshake=handshake,
|
||||
username=username,
|
||||
password=password1,
|
||||
database=database,
|
||||
charset=charset,
|
||||
client_flags=client_flags,
|
||||
max_allowed_packet=max_allowed_packet,
|
||||
auth_plugin=auth_plugin,
|
||||
auth_plugin_class=auth_plugin_class,
|
||||
conn_attrs=conn_attrs,
|
||||
is_change_user_request=is_change_user_request,
|
||||
ssl_enabled=self.ssl_enabled,
|
||||
plugin_config=self.plugin_config,
|
||||
)
|
||||
|
||||
# client sends transaction response
|
||||
send_args = (
|
||||
(0, 0, write_timeout)
|
||||
if is_change_user_request
|
||||
else (None, None, write_timeout)
|
||||
)
|
||||
sock.send(response_payload, *send_args)
|
||||
|
||||
# server replies back
|
||||
pkt = bytes(sock.recv(read_timeout))
|
||||
|
||||
ok_pkt = self._handle_server_response(sock, pkt)
|
||||
if ok_pkt is None:
|
||||
raise InterfaceError("Got a NULL ok_pkt") from None
|
||||
|
||||
return ok_pkt
|
||||
@@ -0,0 +1,620 @@
|
||||
# -*- coding: utf-8 -*- # pylint: disable=missing-module-docstring
|
||||
|
||||
# Copyright (c) 2013, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
"""This module contains the MySQL Server Character Sets.""" # pylint: disable=pointless-string-statement
|
||||
|
||||
# This file was auto-generated.
|
||||
_GENERATED_ON: str = "2022-05-09"
|
||||
_MYSQL_VERSION: Tuple[int, int, int] = (8, 0, 30)
|
||||
|
||||
MYSQL_CHARACTER_SETS: List[Optional[Tuple[str, str, bool]]] = [
|
||||
# (character set name, collation, default)
|
||||
None,
|
||||
("big5", "big5_chinese_ci", True), # 1
|
||||
("latin2", "latin2_czech_cs", False), # 2
|
||||
("dec8", "dec8_swedish_ci", True), # 3
|
||||
("cp850", "cp850_general_ci", True), # 4
|
||||
("latin1", "latin1_german1_ci", False), # 5
|
||||
("hp8", "hp8_english_ci", True), # 6
|
||||
("koi8r", "koi8r_general_ci", True), # 7
|
||||
("latin1", "latin1_swedish_ci", True), # 8
|
||||
("latin2", "latin2_general_ci", True), # 9
|
||||
("swe7", "swe7_swedish_ci", True), # 10
|
||||
("ascii", "ascii_general_ci", True), # 11
|
||||
("ujis", "ujis_japanese_ci", True), # 12
|
||||
("sjis", "sjis_japanese_ci", True), # 13
|
||||
("cp1251", "cp1251_bulgarian_ci", False), # 14
|
||||
("latin1", "latin1_danish_ci", False), # 15
|
||||
("hebrew", "hebrew_general_ci", True), # 16
|
||||
None,
|
||||
("tis620", "tis620_thai_ci", True), # 18
|
||||
("euckr", "euckr_korean_ci", True), # 19
|
||||
("latin7", "latin7_estonian_cs", False), # 20
|
||||
("latin2", "latin2_hungarian_ci", False), # 21
|
||||
("koi8u", "koi8u_general_ci", True), # 22
|
||||
("cp1251", "cp1251_ukrainian_ci", False), # 23
|
||||
("gb2312", "gb2312_chinese_ci", True), # 24
|
||||
("greek", "greek_general_ci", True), # 25
|
||||
("cp1250", "cp1250_general_ci", True), # 26
|
||||
("latin2", "latin2_croatian_ci", False), # 27
|
||||
("gbk", "gbk_chinese_ci", True), # 28
|
||||
("cp1257", "cp1257_lithuanian_ci", False), # 29
|
||||
("latin5", "latin5_turkish_ci", True), # 30
|
||||
("latin1", "latin1_german2_ci", False), # 31
|
||||
("armscii8", "armscii8_general_ci", True), # 32
|
||||
("utf8mb3", "utf8mb3_general_ci", True), # 33
|
||||
("cp1250", "cp1250_czech_cs", False), # 34
|
||||
("ucs2", "ucs2_general_ci", True), # 35
|
||||
("cp866", "cp866_general_ci", True), # 36
|
||||
("keybcs2", "keybcs2_general_ci", True), # 37
|
||||
("macce", "macce_general_ci", True), # 38
|
||||
("macroman", "macroman_general_ci", True), # 39
|
||||
("cp852", "cp852_general_ci", True), # 40
|
||||
("latin7", "latin7_general_ci", True), # 41
|
||||
("latin7", "latin7_general_cs", False), # 42
|
||||
("macce", "macce_bin", False), # 43
|
||||
("cp1250", "cp1250_croatian_ci", False), # 44
|
||||
("utf8mb4", "utf8mb4_general_ci", False), # 45
|
||||
("utf8mb4", "utf8mb4_bin", False), # 46
|
||||
("latin1", "latin1_bin", False), # 47
|
||||
("latin1", "latin1_general_ci", False), # 48
|
||||
("latin1", "latin1_general_cs", False), # 49
|
||||
("cp1251", "cp1251_bin", False), # 50
|
||||
("cp1251", "cp1251_general_ci", True), # 51
|
||||
("cp1251", "cp1251_general_cs", False), # 52
|
||||
("macroman", "macroman_bin", False), # 53
|
||||
("utf16", "utf16_general_ci", True), # 54
|
||||
("utf16", "utf16_bin", False), # 55
|
||||
("utf16le", "utf16le_general_ci", True), # 56
|
||||
("cp1256", "cp1256_general_ci", True), # 57
|
||||
("cp1257", "cp1257_bin", False), # 58
|
||||
("cp1257", "cp1257_general_ci", True), # 59
|
||||
("utf32", "utf32_general_ci", True), # 60
|
||||
("utf32", "utf32_bin", False), # 61
|
||||
("utf16le", "utf16le_bin", False), # 62
|
||||
("binary", "binary", True), # 63
|
||||
("armscii8", "armscii8_bin", False), # 64
|
||||
("ascii", "ascii_bin", False), # 65
|
||||
("cp1250", "cp1250_bin", False), # 66
|
||||
("cp1256", "cp1256_bin", False), # 67
|
||||
("cp866", "cp866_bin", False), # 68
|
||||
("dec8", "dec8_bin", False), # 69
|
||||
("greek", "greek_bin", False), # 70
|
||||
("hebrew", "hebrew_bin", False), # 71
|
||||
("hp8", "hp8_bin", False), # 72
|
||||
("keybcs2", "keybcs2_bin", False), # 73
|
||||
("koi8r", "koi8r_bin", False), # 74
|
||||
("koi8u", "koi8u_bin", False), # 75
|
||||
("utf8mb3", "utf8mb3_tolower_ci", False), # 76
|
||||
("latin2", "latin2_bin", False), # 77
|
||||
("latin5", "latin5_bin", False), # 78
|
||||
("latin7", "latin7_bin", False), # 79
|
||||
("cp850", "cp850_bin", False), # 80
|
||||
("cp852", "cp852_bin", False), # 81
|
||||
("swe7", "swe7_bin", False), # 82
|
||||
("utf8mb3", "utf8mb3_bin", False), # 83
|
||||
("big5", "big5_bin", False), # 84
|
||||
("euckr", "euckr_bin", False), # 85
|
||||
("gb2312", "gb2312_bin", False), # 86
|
||||
("gbk", "gbk_bin", False), # 87
|
||||
("sjis", "sjis_bin", False), # 88
|
||||
("tis620", "tis620_bin", False), # 89
|
||||
("ucs2", "ucs2_bin", False), # 90
|
||||
("ujis", "ujis_bin", False), # 91
|
||||
("geostd8", "geostd8_general_ci", True), # 92
|
||||
("geostd8", "geostd8_bin", False), # 93
|
||||
("latin1", "latin1_spanish_ci", False), # 94
|
||||
("cp932", "cp932_japanese_ci", True), # 95
|
||||
("cp932", "cp932_bin", False), # 96
|
||||
("eucjpms", "eucjpms_japanese_ci", True), # 97
|
||||
("eucjpms", "eucjpms_bin", False), # 98
|
||||
("cp1250", "cp1250_polish_ci", False), # 99
|
||||
None,
|
||||
("utf16", "utf16_unicode_ci", False), # 101
|
||||
("utf16", "utf16_icelandic_ci", False), # 102
|
||||
("utf16", "utf16_latvian_ci", False), # 103
|
||||
("utf16", "utf16_romanian_ci", False), # 104
|
||||
("utf16", "utf16_slovenian_ci", False), # 105
|
||||
("utf16", "utf16_polish_ci", False), # 106
|
||||
("utf16", "utf16_estonian_ci", False), # 107
|
||||
("utf16", "utf16_spanish_ci", False), # 108
|
||||
("utf16", "utf16_swedish_ci", False), # 109
|
||||
("utf16", "utf16_turkish_ci", False), # 110
|
||||
("utf16", "utf16_czech_ci", False), # 111
|
||||
("utf16", "utf16_danish_ci", False), # 112
|
||||
("utf16", "utf16_lithuanian_ci", False), # 113
|
||||
("utf16", "utf16_slovak_ci", False), # 114
|
||||
("utf16", "utf16_spanish2_ci", False), # 115
|
||||
("utf16", "utf16_roman_ci", False), # 116
|
||||
("utf16", "utf16_persian_ci", False), # 117
|
||||
("utf16", "utf16_esperanto_ci", False), # 118
|
||||
("utf16", "utf16_hungarian_ci", False), # 119
|
||||
("utf16", "utf16_sinhala_ci", False), # 120
|
||||
("utf16", "utf16_german2_ci", False), # 121
|
||||
("utf16", "utf16_croatian_ci", False), # 122
|
||||
("utf16", "utf16_unicode_520_ci", False), # 123
|
||||
("utf16", "utf16_vietnamese_ci", False), # 124
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("ucs2", "ucs2_unicode_ci", False), # 128
|
||||
("ucs2", "ucs2_icelandic_ci", False), # 129
|
||||
("ucs2", "ucs2_latvian_ci", False), # 130
|
||||
("ucs2", "ucs2_romanian_ci", False), # 131
|
||||
("ucs2", "ucs2_slovenian_ci", False), # 132
|
||||
("ucs2", "ucs2_polish_ci", False), # 133
|
||||
("ucs2", "ucs2_estonian_ci", False), # 134
|
||||
("ucs2", "ucs2_spanish_ci", False), # 135
|
||||
("ucs2", "ucs2_swedish_ci", False), # 136
|
||||
("ucs2", "ucs2_turkish_ci", False), # 137
|
||||
("ucs2", "ucs2_czech_ci", False), # 138
|
||||
("ucs2", "ucs2_danish_ci", False), # 139
|
||||
("ucs2", "ucs2_lithuanian_ci", False), # 140
|
||||
("ucs2", "ucs2_slovak_ci", False), # 141
|
||||
("ucs2", "ucs2_spanish2_ci", False), # 142
|
||||
("ucs2", "ucs2_roman_ci", False), # 143
|
||||
("ucs2", "ucs2_persian_ci", False), # 144
|
||||
("ucs2", "ucs2_esperanto_ci", False), # 145
|
||||
("ucs2", "ucs2_hungarian_ci", False), # 146
|
||||
("ucs2", "ucs2_sinhala_ci", False), # 147
|
||||
("ucs2", "ucs2_german2_ci", False), # 148
|
||||
("ucs2", "ucs2_croatian_ci", False), # 149
|
||||
("ucs2", "ucs2_unicode_520_ci", False), # 150
|
||||
("ucs2", "ucs2_vietnamese_ci", False), # 151
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("ucs2", "ucs2_general_mysql500_ci", False), # 159
|
||||
("utf32", "utf32_unicode_ci", False), # 160
|
||||
("utf32", "utf32_icelandic_ci", False), # 161
|
||||
("utf32", "utf32_latvian_ci", False), # 162
|
||||
("utf32", "utf32_romanian_ci", False), # 163
|
||||
("utf32", "utf32_slovenian_ci", False), # 164
|
||||
("utf32", "utf32_polish_ci", False), # 165
|
||||
("utf32", "utf32_estonian_ci", False), # 166
|
||||
("utf32", "utf32_spanish_ci", False), # 167
|
||||
("utf32", "utf32_swedish_ci", False), # 168
|
||||
("utf32", "utf32_turkish_ci", False), # 169
|
||||
("utf32", "utf32_czech_ci", False), # 170
|
||||
("utf32", "utf32_danish_ci", False), # 171
|
||||
("utf32", "utf32_lithuanian_ci", False), # 172
|
||||
("utf32", "utf32_slovak_ci", False), # 173
|
||||
("utf32", "utf32_spanish2_ci", False), # 174
|
||||
("utf32", "utf32_roman_ci", False), # 175
|
||||
("utf32", "utf32_persian_ci", False), # 176
|
||||
("utf32", "utf32_esperanto_ci", False), # 177
|
||||
("utf32", "utf32_hungarian_ci", False), # 178
|
||||
("utf32", "utf32_sinhala_ci", False), # 179
|
||||
("utf32", "utf32_german2_ci", False), # 180
|
||||
("utf32", "utf32_croatian_ci", False), # 181
|
||||
("utf32", "utf32_unicode_520_ci", False), # 182
|
||||
("utf32", "utf32_vietnamese_ci", False), # 183
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("utf8mb3", "utf8mb3_unicode_ci", False), # 192
|
||||
("utf8mb3", "utf8mb3_icelandic_ci", False), # 193
|
||||
("utf8mb3", "utf8mb3_latvian_ci", False), # 194
|
||||
("utf8mb3", "utf8mb3_romanian_ci", False), # 195
|
||||
("utf8mb3", "utf8mb3_slovenian_ci", False), # 196
|
||||
("utf8mb3", "utf8mb3_polish_ci", False), # 197
|
||||
("utf8mb3", "utf8mb3_estonian_ci", False), # 198
|
||||
("utf8mb3", "utf8mb3_spanish_ci", False), # 199
|
||||
("utf8mb3", "utf8mb3_swedish_ci", False), # 200
|
||||
("utf8mb3", "utf8mb3_turkish_ci", False), # 201
|
||||
("utf8mb3", "utf8mb3_czech_ci", False), # 202
|
||||
("utf8mb3", "utf8mb3_danish_ci", False), # 203
|
||||
("utf8mb3", "utf8mb3_lithuanian_ci", False), # 204
|
||||
("utf8mb3", "utf8mb3_slovak_ci", False), # 205
|
||||
("utf8mb3", "utf8mb3_spanish2_ci", False), # 206
|
||||
("utf8mb3", "utf8mb3_roman_ci", False), # 207
|
||||
("utf8mb3", "utf8mb3_persian_ci", False), # 208
|
||||
("utf8mb3", "utf8mb3_esperanto_ci", False), # 209
|
||||
("utf8mb3", "utf8mb3_hungarian_ci", False), # 210
|
||||
("utf8mb3", "utf8mb3_sinhala_ci", False), # 211
|
||||
("utf8mb3", "utf8mb3_german2_ci", False), # 212
|
||||
("utf8mb3", "utf8mb3_croatian_ci", False), # 213
|
||||
("utf8mb3", "utf8mb3_unicode_520_ci", False), # 214
|
||||
("utf8mb3", "utf8mb3_vietnamese_ci", False), # 215
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("utf8mb3", "utf8mb3_general_mysql500_ci", False), # 223
|
||||
("utf8mb4", "utf8mb4_unicode_ci", False), # 224
|
||||
("utf8mb4", "utf8mb4_icelandic_ci", False), # 225
|
||||
("utf8mb4", "utf8mb4_latvian_ci", False), # 226
|
||||
("utf8mb4", "utf8mb4_romanian_ci", False), # 227
|
||||
("utf8mb4", "utf8mb4_slovenian_ci", False), # 228
|
||||
("utf8mb4", "utf8mb4_polish_ci", False), # 229
|
||||
("utf8mb4", "utf8mb4_estonian_ci", False), # 230
|
||||
("utf8mb4", "utf8mb4_spanish_ci", False), # 231
|
||||
("utf8mb4", "utf8mb4_swedish_ci", False), # 232
|
||||
("utf8mb4", "utf8mb4_turkish_ci", False), # 233
|
||||
("utf8mb4", "utf8mb4_czech_ci", False), # 234
|
||||
("utf8mb4", "utf8mb4_danish_ci", False), # 235
|
||||
("utf8mb4", "utf8mb4_lithuanian_ci", False), # 236
|
||||
("utf8mb4", "utf8mb4_slovak_ci", False), # 237
|
||||
("utf8mb4", "utf8mb4_spanish2_ci", False), # 238
|
||||
("utf8mb4", "utf8mb4_roman_ci", False), # 239
|
||||
("utf8mb4", "utf8mb4_persian_ci", False), # 240
|
||||
("utf8mb4", "utf8mb4_esperanto_ci", False), # 241
|
||||
("utf8mb4", "utf8mb4_hungarian_ci", False), # 242
|
||||
("utf8mb4", "utf8mb4_sinhala_ci", False), # 243
|
||||
("utf8mb4", "utf8mb4_german2_ci", False), # 244
|
||||
("utf8mb4", "utf8mb4_croatian_ci", False), # 245
|
||||
("utf8mb4", "utf8mb4_unicode_520_ci", False), # 246
|
||||
("utf8mb4", "utf8mb4_vietnamese_ci", False), # 247
|
||||
("gb18030", "gb18030_chinese_ci", True), # 248
|
||||
("gb18030", "gb18030_bin", False), # 249
|
||||
("gb18030", "gb18030_unicode_520_ci", False), # 250
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("utf8mb4", "utf8mb4_0900_ai_ci", True), # 255
|
||||
("utf8mb4", "utf8mb4_de_pb_0900_ai_ci", False), # 256
|
||||
("utf8mb4", "utf8mb4_is_0900_ai_ci", False), # 257
|
||||
("utf8mb4", "utf8mb4_lv_0900_ai_ci", False), # 258
|
||||
("utf8mb4", "utf8mb4_ro_0900_ai_ci", False), # 259
|
||||
("utf8mb4", "utf8mb4_sl_0900_ai_ci", False), # 260
|
||||
("utf8mb4", "utf8mb4_pl_0900_ai_ci", False), # 261
|
||||
("utf8mb4", "utf8mb4_et_0900_ai_ci", False), # 262
|
||||
("utf8mb4", "utf8mb4_es_0900_ai_ci", False), # 263
|
||||
("utf8mb4", "utf8mb4_sv_0900_ai_ci", False), # 264
|
||||
("utf8mb4", "utf8mb4_tr_0900_ai_ci", False), # 265
|
||||
("utf8mb4", "utf8mb4_cs_0900_ai_ci", False), # 266
|
||||
("utf8mb4", "utf8mb4_da_0900_ai_ci", False), # 267
|
||||
("utf8mb4", "utf8mb4_lt_0900_ai_ci", False), # 268
|
||||
("utf8mb4", "utf8mb4_sk_0900_ai_ci", False), # 269
|
||||
("utf8mb4", "utf8mb4_es_trad_0900_ai_ci", False), # 270
|
||||
("utf8mb4", "utf8mb4_la_0900_ai_ci", False), # 271
|
||||
None,
|
||||
("utf8mb4", "utf8mb4_eo_0900_ai_ci", False), # 273
|
||||
("utf8mb4", "utf8mb4_hu_0900_ai_ci", False), # 274
|
||||
("utf8mb4", "utf8mb4_hr_0900_ai_ci", False), # 275
|
||||
None,
|
||||
("utf8mb4", "utf8mb4_vi_0900_ai_ci", False), # 277
|
||||
("utf8mb4", "utf8mb4_0900_as_cs", False), # 278
|
||||
("utf8mb4", "utf8mb4_de_pb_0900_as_cs", False), # 279
|
||||
("utf8mb4", "utf8mb4_is_0900_as_cs", False), # 280
|
||||
("utf8mb4", "utf8mb4_lv_0900_as_cs", False), # 281
|
||||
("utf8mb4", "utf8mb4_ro_0900_as_cs", False), # 282
|
||||
("utf8mb4", "utf8mb4_sl_0900_as_cs", False), # 283
|
||||
("utf8mb4", "utf8mb4_pl_0900_as_cs", False), # 284
|
||||
("utf8mb4", "utf8mb4_et_0900_as_cs", False), # 285
|
||||
("utf8mb4", "utf8mb4_es_0900_as_cs", False), # 286
|
||||
("utf8mb4", "utf8mb4_sv_0900_as_cs", False), # 287
|
||||
("utf8mb4", "utf8mb4_tr_0900_as_cs", False), # 288
|
||||
("utf8mb4", "utf8mb4_cs_0900_as_cs", False), # 289
|
||||
("utf8mb4", "utf8mb4_da_0900_as_cs", False), # 290
|
||||
("utf8mb4", "utf8mb4_lt_0900_as_cs", False), # 291
|
||||
("utf8mb4", "utf8mb4_sk_0900_as_cs", False), # 292
|
||||
("utf8mb4", "utf8mb4_es_trad_0900_as_cs", False), # 293
|
||||
("utf8mb4", "utf8mb4_la_0900_as_cs", False), # 294
|
||||
None,
|
||||
("utf8mb4", "utf8mb4_eo_0900_as_cs", False), # 296
|
||||
("utf8mb4", "utf8mb4_hu_0900_as_cs", False), # 297
|
||||
("utf8mb4", "utf8mb4_hr_0900_as_cs", False), # 298
|
||||
None,
|
||||
("utf8mb4", "utf8mb4_vi_0900_as_cs", False), # 300
|
||||
None,
|
||||
None,
|
||||
("utf8mb4", "utf8mb4_ja_0900_as_cs", False), # 303
|
||||
("utf8mb4", "utf8mb4_ja_0900_as_cs_ks", False), # 304
|
||||
("utf8mb4", "utf8mb4_0900_as_ci", False), # 305
|
||||
("utf8mb4", "utf8mb4_ru_0900_ai_ci", False), # 306
|
||||
("utf8mb4", "utf8mb4_ru_0900_as_cs", False), # 307
|
||||
("utf8mb4", "utf8mb4_zh_0900_as_cs", False), # 308
|
||||
("utf8mb4", "utf8mb4_0900_bin", False), # 309
|
||||
("utf8mb4", "utf8mb4_nb_0900_ai_ci", False), # 310
|
||||
("utf8mb4", "utf8mb4_nb_0900_as_cs", False), # 311
|
||||
("utf8mb4", "utf8mb4_nn_0900_ai_ci", False), # 312
|
||||
("utf8mb4", "utf8mb4_nn_0900_as_cs", False), # 313
|
||||
("utf8mb4", "utf8mb4_sr_latn_0900_ai_ci", False), # 314
|
||||
("utf8mb4", "utf8mb4_sr_latn_0900_as_cs", False), # 315
|
||||
("utf8mb4", "utf8mb4_bs_0900_ai_ci", False), # 316
|
||||
("utf8mb4", "utf8mb4_bs_0900_as_cs", False), # 317
|
||||
("utf8mb4", "utf8mb4_bg_0900_ai_ci", False), # 318
|
||||
("utf8mb4", "utf8mb4_bg_0900_as_cs", False), # 319
|
||||
("utf8mb4", "utf8mb4_gl_0900_ai_ci", False), # 320
|
||||
("utf8mb4", "utf8mb4_gl_0900_as_cs", False), # 321
|
||||
("utf8mb4", "utf8mb4_mn_cyrl_0900_ai_ci", False), # 322
|
||||
("utf8mb4", "utf8mb4_mn_cyrl_0900_as_cs", False), # 323
|
||||
]
|
||||
|
||||
MYSQL_CHARACTER_SETS_57: List[Optional[Tuple[str, str, bool]]] = [
|
||||
# (character set name, collation, default)
|
||||
None,
|
||||
("big5", "big5_chinese_ci", True), # 1
|
||||
("latin2", "latin2_czech_cs", False), # 2
|
||||
("dec8", "dec8_swedish_ci", True), # 3
|
||||
("cp850", "cp850_general_ci", True), # 4
|
||||
("latin1", "latin1_german1_ci", False), # 5
|
||||
("hp8", "hp8_english_ci", True), # 6
|
||||
("koi8r", "koi8r_general_ci", True), # 7
|
||||
("latin1", "latin1_swedish_ci", True), # 8
|
||||
("latin2", "latin2_general_ci", True), # 9
|
||||
("swe7", "swe7_swedish_ci", True), # 10
|
||||
("ascii", "ascii_general_ci", True), # 11
|
||||
("ujis", "ujis_japanese_ci", True), # 12
|
||||
("sjis", "sjis_japanese_ci", True), # 13
|
||||
("cp1251", "cp1251_bulgarian_ci", False), # 14
|
||||
("latin1", "latin1_danish_ci", False), # 15
|
||||
("hebrew", "hebrew_general_ci", True), # 16
|
||||
None,
|
||||
("tis620", "tis620_thai_ci", True), # 18
|
||||
("euckr", "euckr_korean_ci", True), # 19
|
||||
("latin7", "latin7_estonian_cs", False), # 20
|
||||
("latin2", "latin2_hungarian_ci", False), # 21
|
||||
("koi8u", "koi8u_general_ci", True), # 22
|
||||
("cp1251", "cp1251_ukrainian_ci", False), # 23
|
||||
("gb2312", "gb2312_chinese_ci", True), # 24
|
||||
("greek", "greek_general_ci", True), # 25
|
||||
("cp1250", "cp1250_general_ci", True), # 26
|
||||
("latin2", "latin2_croatian_ci", False), # 27
|
||||
("gbk", "gbk_chinese_ci", True), # 28
|
||||
("cp1257", "cp1257_lithuanian_ci", False), # 29
|
||||
("latin5", "latin5_turkish_ci", True), # 30
|
||||
("latin1", "latin1_german2_ci", False), # 31
|
||||
("armscii8", "armscii8_general_ci", True), # 32
|
||||
("utf8", "utf8_general_ci", True), # 33
|
||||
("cp1250", "cp1250_czech_cs", False), # 34
|
||||
("ucs2", "ucs2_general_ci", True), # 35
|
||||
("cp866", "cp866_general_ci", True), # 36
|
||||
("keybcs2", "keybcs2_general_ci", True), # 37
|
||||
("macce", "macce_general_ci", True), # 38
|
||||
("macroman", "macroman_general_ci", True), # 39
|
||||
("cp852", "cp852_general_ci", True), # 40
|
||||
("latin7", "latin7_general_ci", True), # 41
|
||||
("latin7", "latin7_general_cs", False), # 42
|
||||
("macce", "macce_bin", False), # 43
|
||||
("cp1250", "cp1250_croatian_ci", False), # 44
|
||||
("utf8mb4", "utf8mb4_general_ci", True), # 45
|
||||
("utf8mb4", "utf8mb4_bin", False), # 46
|
||||
("latin1", "latin1_bin", False), # 47
|
||||
("latin1", "latin1_general_ci", False), # 48
|
||||
("latin1", "latin1_general_cs", False), # 49
|
||||
("cp1251", "cp1251_bin", False), # 50
|
||||
("cp1251", "cp1251_general_ci", True), # 51
|
||||
("cp1251", "cp1251_general_cs", False), # 52
|
||||
("macroman", "macroman_bin", False), # 53
|
||||
("utf16", "utf16_general_ci", True), # 54
|
||||
("utf16", "utf16_bin", False), # 55
|
||||
("utf16le", "utf16le_general_ci", True), # 56
|
||||
("cp1256", "cp1256_general_ci", True), # 57
|
||||
("cp1257", "cp1257_bin", False), # 58
|
||||
("cp1257", "cp1257_general_ci", True), # 59
|
||||
("utf32", "utf32_general_ci", True), # 60
|
||||
("utf32", "utf32_bin", False), # 61
|
||||
("utf16le", "utf16le_bin", False), # 62
|
||||
("binary", "binary", True), # 63
|
||||
("armscii8", "armscii8_bin", False), # 64
|
||||
("ascii", "ascii_bin", False), # 65
|
||||
("cp1250", "cp1250_bin", False), # 66
|
||||
("cp1256", "cp1256_bin", False), # 67
|
||||
("cp866", "cp866_bin", False), # 68
|
||||
("dec8", "dec8_bin", False), # 69
|
||||
("greek", "greek_bin", False), # 70
|
||||
("hebrew", "hebrew_bin", False), # 71
|
||||
("hp8", "hp8_bin", False), # 72
|
||||
("keybcs2", "keybcs2_bin", False), # 73
|
||||
("koi8r", "koi8r_bin", False), # 74
|
||||
("koi8u", "koi8u_bin", False), # 75
|
||||
None,
|
||||
("latin2", "latin2_bin", False), # 77
|
||||
("latin5", "latin5_bin", False), # 78
|
||||
("latin7", "latin7_bin", False), # 79
|
||||
("cp850", "cp850_bin", False), # 80
|
||||
("cp852", "cp852_bin", False), # 81
|
||||
("swe7", "swe7_bin", False), # 82
|
||||
("utf8", "utf8_bin", False), # 83
|
||||
("big5", "big5_bin", False), # 84
|
||||
("euckr", "euckr_bin", False), # 85
|
||||
("gb2312", "gb2312_bin", False), # 86
|
||||
("gbk", "gbk_bin", False), # 87
|
||||
("sjis", "sjis_bin", False), # 88
|
||||
("tis620", "tis620_bin", False), # 89
|
||||
("ucs2", "ucs2_bin", False), # 90
|
||||
("ujis", "ujis_bin", False), # 91
|
||||
("geostd8", "geostd8_general_ci", True), # 92
|
||||
("geostd8", "geostd8_bin", False), # 93
|
||||
("latin1", "latin1_spanish_ci", False), # 94
|
||||
("cp932", "cp932_japanese_ci", True), # 95
|
||||
("cp932", "cp932_bin", False), # 96
|
||||
("eucjpms", "eucjpms_japanese_ci", True), # 97
|
||||
("eucjpms", "eucjpms_bin", False), # 98
|
||||
("cp1250", "cp1250_polish_ci", False), # 99
|
||||
None,
|
||||
("utf16", "utf16_unicode_ci", False), # 101
|
||||
("utf16", "utf16_icelandic_ci", False), # 102
|
||||
("utf16", "utf16_latvian_ci", False), # 103
|
||||
("utf16", "utf16_romanian_ci", False), # 104
|
||||
("utf16", "utf16_slovenian_ci", False), # 105
|
||||
("utf16", "utf16_polish_ci", False), # 106
|
||||
("utf16", "utf16_estonian_ci", False), # 107
|
||||
("utf16", "utf16_spanish_ci", False), # 108
|
||||
("utf16", "utf16_swedish_ci", False), # 109
|
||||
("utf16", "utf16_turkish_ci", False), # 110
|
||||
("utf16", "utf16_czech_ci", False), # 111
|
||||
("utf16", "utf16_danish_ci", False), # 112
|
||||
("utf16", "utf16_lithuanian_ci", False), # 113
|
||||
("utf16", "utf16_slovak_ci", False), # 114
|
||||
("utf16", "utf16_spanish2_ci", False), # 115
|
||||
("utf16", "utf16_roman_ci", False), # 116
|
||||
("utf16", "utf16_persian_ci", False), # 117
|
||||
("utf16", "utf16_esperanto_ci", False), # 118
|
||||
("utf16", "utf16_hungarian_ci", False), # 119
|
||||
("utf16", "utf16_sinhala_ci", False), # 120
|
||||
("utf16", "utf16_german2_ci", False), # 121
|
||||
("utf16", "utf16_croatian_ci", False), # 122
|
||||
("utf16", "utf16_unicode_520_ci", False), # 123
|
||||
("utf16", "utf16_vietnamese_ci", False), # 124
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("ucs2", "ucs2_unicode_ci", False), # 128
|
||||
("ucs2", "ucs2_icelandic_ci", False), # 129
|
||||
("ucs2", "ucs2_latvian_ci", False), # 130
|
||||
("ucs2", "ucs2_romanian_ci", False), # 131
|
||||
("ucs2", "ucs2_slovenian_ci", False), # 132
|
||||
("ucs2", "ucs2_polish_ci", False), # 133
|
||||
("ucs2", "ucs2_estonian_ci", False), # 134
|
||||
("ucs2", "ucs2_spanish_ci", False), # 135
|
||||
("ucs2", "ucs2_swedish_ci", False), # 136
|
||||
("ucs2", "ucs2_turkish_ci", False), # 137
|
||||
("ucs2", "ucs2_czech_ci", False), # 138
|
||||
("ucs2", "ucs2_danish_ci", False), # 139
|
||||
("ucs2", "ucs2_lithuanian_ci", False), # 140
|
||||
("ucs2", "ucs2_slovak_ci", False), # 141
|
||||
("ucs2", "ucs2_spanish2_ci", False), # 142
|
||||
("ucs2", "ucs2_roman_ci", False), # 143
|
||||
("ucs2", "ucs2_persian_ci", False), # 144
|
||||
("ucs2", "ucs2_esperanto_ci", False), # 145
|
||||
("ucs2", "ucs2_hungarian_ci", False), # 146
|
||||
("ucs2", "ucs2_sinhala_ci", False), # 147
|
||||
("ucs2", "ucs2_german2_ci", False), # 148
|
||||
("ucs2", "ucs2_croatian_ci", False), # 149
|
||||
("ucs2", "ucs2_unicode_520_ci", False), # 150
|
||||
("ucs2", "ucs2_vietnamese_ci", False), # 151
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("ucs2", "ucs2_general_mysql500_ci", False), # 159
|
||||
("utf32", "utf32_unicode_ci", False), # 160
|
||||
("utf32", "utf32_icelandic_ci", False), # 161
|
||||
("utf32", "utf32_latvian_ci", False), # 162
|
||||
("utf32", "utf32_romanian_ci", False), # 163
|
||||
("utf32", "utf32_slovenian_ci", False), # 164
|
||||
("utf32", "utf32_polish_ci", False), # 165
|
||||
("utf32", "utf32_estonian_ci", False), # 166
|
||||
("utf32", "utf32_spanish_ci", False), # 167
|
||||
("utf32", "utf32_swedish_ci", False), # 168
|
||||
("utf32", "utf32_turkish_ci", False), # 169
|
||||
("utf32", "utf32_czech_ci", False), # 170
|
||||
("utf32", "utf32_danish_ci", False), # 171
|
||||
("utf32", "utf32_lithuanian_ci", False), # 172
|
||||
("utf32", "utf32_slovak_ci", False), # 173
|
||||
("utf32", "utf32_spanish2_ci", False), # 174
|
||||
("utf32", "utf32_roman_ci", False), # 175
|
||||
("utf32", "utf32_persian_ci", False), # 176
|
||||
("utf32", "utf32_esperanto_ci", False), # 177
|
||||
("utf32", "utf32_hungarian_ci", False), # 178
|
||||
("utf32", "utf32_sinhala_ci", False), # 179
|
||||
("utf32", "utf32_german2_ci", False), # 180
|
||||
("utf32", "utf32_croatian_ci", False), # 181
|
||||
("utf32", "utf32_unicode_520_ci", False), # 182
|
||||
("utf32", "utf32_vietnamese_ci", False), # 183
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("utf8", "utf8_unicode_ci", False), # 192
|
||||
("utf8", "utf8_icelandic_ci", False), # 193
|
||||
("utf8", "utf8_latvian_ci", False), # 194
|
||||
("utf8", "utf8_romanian_ci", False), # 195
|
||||
("utf8", "utf8_slovenian_ci", False), # 196
|
||||
("utf8", "utf8_polish_ci", False), # 197
|
||||
("utf8", "utf8_estonian_ci", False), # 198
|
||||
("utf8", "utf8_spanish_ci", False), # 199
|
||||
("utf8", "utf8_swedish_ci", False), # 200
|
||||
("utf8", "utf8_turkish_ci", False), # 201
|
||||
("utf8", "utf8_czech_ci", False), # 202
|
||||
("utf8", "utf8_danish_ci", False), # 203
|
||||
("utf8", "utf8_lithuanian_ci", False), # 204
|
||||
("utf8", "utf8_slovak_ci", False), # 205
|
||||
("utf8", "utf8_spanish2_ci", False), # 206
|
||||
("utf8", "utf8_roman_ci", False), # 207
|
||||
("utf8", "utf8_persian_ci", False), # 208
|
||||
("utf8", "utf8_esperanto_ci", False), # 209
|
||||
("utf8", "utf8_hungarian_ci", False), # 210
|
||||
("utf8", "utf8_sinhala_ci", False), # 211
|
||||
("utf8", "utf8_german2_ci", False), # 212
|
||||
("utf8", "utf8_croatian_ci", False), # 213
|
||||
("utf8", "utf8_unicode_520_ci", False), # 214
|
||||
("utf8", "utf8_vietnamese_ci", False), # 215
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("utf8", "utf8_general_mysql500_ci", False), # 223
|
||||
("utf8mb4", "utf8mb4_unicode_ci", False), # 224
|
||||
("utf8mb4", "utf8mb4_icelandic_ci", False), # 225
|
||||
("utf8mb4", "utf8mb4_latvian_ci", False), # 226
|
||||
("utf8mb4", "utf8mb4_romanian_ci", False), # 227
|
||||
("utf8mb4", "utf8mb4_slovenian_ci", False), # 228
|
||||
("utf8mb4", "utf8mb4_polish_ci", False), # 229
|
||||
("utf8mb4", "utf8mb4_estonian_ci", False), # 230
|
||||
("utf8mb4", "utf8mb4_spanish_ci", False), # 231
|
||||
("utf8mb4", "utf8mb4_swedish_ci", False), # 232
|
||||
("utf8mb4", "utf8mb4_turkish_ci", False), # 233
|
||||
("utf8mb4", "utf8mb4_czech_ci", False), # 234
|
||||
("utf8mb4", "utf8mb4_danish_ci", False), # 235
|
||||
("utf8mb4", "utf8mb4_lithuanian_ci", False), # 236
|
||||
("utf8mb4", "utf8mb4_slovak_ci", False), # 237
|
||||
("utf8mb4", "utf8mb4_spanish2_ci", False), # 238
|
||||
("utf8mb4", "utf8mb4_roman_ci", False), # 239
|
||||
("utf8mb4", "utf8mb4_persian_ci", False), # 240
|
||||
("utf8mb4", "utf8mb4_esperanto_ci", False), # 241
|
||||
("utf8mb4", "utf8mb4_hungarian_ci", False), # 242
|
||||
("utf8mb4", "utf8mb4_sinhala_ci", False), # 243
|
||||
("utf8mb4", "utf8mb4_german2_ci", False), # 244
|
||||
("utf8mb4", "utf8mb4_croatian_ci", False), # 245
|
||||
("utf8mb4", "utf8mb4_unicode_520_ci", False), # 246
|
||||
("utf8mb4", "utf8mb4_vietnamese_ci", False), # 247
|
||||
("gb18030", "gb18030_chinese_ci", True), # 248
|
||||
("gb18030", "gb18030_bin", False), # 249
|
||||
("gb18030", "gb18030_unicode_520_ci", False), # 250
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,789 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Converting MySQL and Python types"""
|
||||
|
||||
import array
|
||||
import datetime
|
||||
import math
|
||||
import struct
|
||||
import time
|
||||
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from .constants import (
|
||||
MYSQL_VECTOR_TYPE_CODE,
|
||||
NATIVE_SUPPORTED_CONVERSION_TYPES,
|
||||
CharacterSet,
|
||||
FieldFlag,
|
||||
FieldType,
|
||||
SQLMode,
|
||||
)
|
||||
from .custom_types import HexLiteral
|
||||
from .types import (
|
||||
DescriptionType,
|
||||
MySQLConvertibleType,
|
||||
MySQLProducedType,
|
||||
PythonProducedType,
|
||||
StrOrBytes,
|
||||
)
|
||||
from .utils import NUMERIC_TYPES
|
||||
|
||||
CONVERT_ERROR = "Could not convert '{value}' to python {pytype}"
|
||||
|
||||
|
||||
class MySQLConverterBase:
|
||||
"""Base class for conversion classes
|
||||
|
||||
All class dealing with converting to and from MySQL data types must
|
||||
be a subclass of this class.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
charset: Optional[str] = "utf8",
|
||||
use_unicode: bool = True,
|
||||
str_fallback: bool = False,
|
||||
) -> None:
|
||||
self._character_set: CharacterSet = CharacterSet()
|
||||
self.python_types: Optional[Tuple] = None
|
||||
self.mysql_types: Optional[Tuple] = None
|
||||
self.charset: Optional[str] = None
|
||||
self.charset_id: int = 0
|
||||
self.set_charset(charset)
|
||||
self.use_unicode: bool = use_unicode
|
||||
self.str_fallback: bool = str_fallback
|
||||
self._cache_field_types: Dict[
|
||||
int,
|
||||
Callable[[bytes, DescriptionType], PythonProducedType],
|
||||
] = {}
|
||||
|
||||
def set_charset(
|
||||
self, charset: Optional[str], character_set: Optional[CharacterSet] = None
|
||||
) -> None:
|
||||
"""Set character set"""
|
||||
if charset in ("utf8mb4", "utf8mb3"):
|
||||
charset = "utf8"
|
||||
if charset is not None:
|
||||
self.charset = charset
|
||||
else:
|
||||
# default to utf8
|
||||
self.charset = "utf8"
|
||||
|
||||
if character_set:
|
||||
self._character_set = character_set
|
||||
|
||||
self.charset_id = self._character_set.get_charset_info(self.charset)[0]
|
||||
|
||||
def set_unicode(self, value: bool = True) -> None:
|
||||
"""Set whether to use Unicode"""
|
||||
self.use_unicode = value
|
||||
|
||||
def to_mysql(
|
||||
self, value: MySQLConvertibleType
|
||||
) -> Union[MySQLConvertibleType, HexLiteral]:
|
||||
"""Convert Python data type to MySQL"""
|
||||
type_name = value.__class__.__name__.lower()
|
||||
try:
|
||||
converted: MySQLConvertibleType = getattr(self, f"_{type_name}_to_mysql")(
|
||||
value
|
||||
)
|
||||
return converted
|
||||
except AttributeError:
|
||||
return value
|
||||
|
||||
def to_python(
|
||||
self, vtype: DescriptionType, value: Optional[bytes]
|
||||
) -> PythonProducedType:
|
||||
"""Convert MySQL data type to Python"""
|
||||
|
||||
if (value == b"\x00" or value is None) and vtype[1] != FieldType.BIT:
|
||||
# Don't go further when we hit a NULL value
|
||||
return None
|
||||
|
||||
if not self._cache_field_types:
|
||||
self._cache_field_types = {}
|
||||
for name, info in FieldType.desc.items():
|
||||
try:
|
||||
self._cache_field_types[info[0]] = getattr(
|
||||
self, f"_{name.lower()}_to_python"
|
||||
)
|
||||
except AttributeError:
|
||||
# We ignore field types which has no method
|
||||
pass
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return self._cache_field_types[vtype[1]](value, vtype)
|
||||
except KeyError:
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def escape(
|
||||
value: Any,
|
||||
sql_mode: Optional[Union[str, bytes]] = None, # pylint: disable=unused-argument
|
||||
) -> Any:
|
||||
"""Escape buffer for sending to MySQL"""
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def quote(buf: Any) -> StrOrBytes:
|
||||
"""Quote buffer for sending to MySQL"""
|
||||
return str(buf)
|
||||
|
||||
|
||||
class MySQLConverter(MySQLConverterBase):
|
||||
"""Default conversion class for MySQL Connector/Python.
|
||||
|
||||
o escape method: for escaping values send to MySQL
|
||||
o quoting method: for quoting values send to MySQL in statements
|
||||
o conversion mapping: maps Python and MySQL data types to
|
||||
function for converting them.
|
||||
|
||||
Whenever one needs to convert values differently, a converter_class
|
||||
argument can be given while instantiating a new connection like
|
||||
cnx.connect(converter_class=CustomMySQLConverterClass).
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
charset: Optional[str] = None,
|
||||
use_unicode: bool = True,
|
||||
str_fallback: bool = False,
|
||||
) -> None:
|
||||
super().__init__(charset, use_unicode, str_fallback)
|
||||
self._cache_field_types: Dict[
|
||||
int,
|
||||
Callable[[bytes, DescriptionType], PythonProducedType],
|
||||
] = {}
|
||||
|
||||
@staticmethod
|
||||
def escape(value: Any, sql_mode: Optional[Union[str, bytes]] = None) -> Any:
|
||||
"""
|
||||
Escapes special characters as they are expected to by when MySQL
|
||||
receives them.
|
||||
As found in MySQL source mysys/charset.c
|
||||
|
||||
Returns the value if not a string, or the escaped string.
|
||||
"""
|
||||
if isinstance(sql_mode, bytes):
|
||||
# sql_mode is returned as bytes if use_unicode is set to False during connect()
|
||||
sql_mode = sql_mode.decode()
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
if sql_mode is not None and SQLMode.NO_BACKSLASH_ESCAPES in sql_mode:
|
||||
return value.replace(b"'", b"''")
|
||||
value = value.replace(b"\\", b"\\\\")
|
||||
value = value.replace(b"\n", b"\\n")
|
||||
value = value.replace(b"\r", b"\\r")
|
||||
value = value.replace(b"\047", b"\134\047") # single quotes
|
||||
value = value.replace(b"\042", b"\134\042") # double quotes
|
||||
value = value.replace(b"\032", b"\134\032") # for Win32
|
||||
elif isinstance(value, str) and not isinstance(value, HexLiteral):
|
||||
if sql_mode is not None and SQLMode.NO_BACKSLASH_ESCAPES in sql_mode:
|
||||
return value.replace("'", "''")
|
||||
value = value.replace("\\", "\\\\")
|
||||
value = value.replace("\n", "\\n")
|
||||
value = value.replace("\r", "\\r")
|
||||
value = value.replace("\047", "\134\047") # single quotes
|
||||
value = value.replace("\042", "\134\042") # double quotes
|
||||
value = value.replace("\032", "\134\032") # for Win32
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def quote(buf: Optional[Union[float, int, Decimal, HexLiteral, bytes]]) -> bytes:
|
||||
"""
|
||||
Quote the parameters for commands. General rules:
|
||||
o numbers are returns as bytes using ascii codec
|
||||
o None is returned as bytearray(b'NULL')
|
||||
o Everything else is single quoted '<buf>'
|
||||
|
||||
Returns a bytearray object.
|
||||
"""
|
||||
if isinstance(buf, NUMERIC_TYPES):
|
||||
return str(buf).encode("ascii")
|
||||
if isinstance(buf, type(None)):
|
||||
return bytearray(b"NULL")
|
||||
return bytearray(b"'" + buf + b"'") # type: ignore[operator]
|
||||
|
||||
def to_mysql(self, value: MySQLConvertibleType) -> MySQLProducedType:
|
||||
"""Convert Python data type to MySQL"""
|
||||
if isinstance(value, Enum):
|
||||
value = value.value
|
||||
# check if type of value object matches any one of the native supported conversion types
|
||||
# most of the types will match the condition below
|
||||
type_name: str = NATIVE_SUPPORTED_CONVERSION_TYPES.get(type(value), "")
|
||||
if not type_name:
|
||||
# check if the value object inherits from one of the native supported conversion types
|
||||
type_name = next(
|
||||
(
|
||||
name
|
||||
for data_type, name in NATIVE_SUPPORTED_CONVERSION_TYPES.items()
|
||||
if isinstance(value, data_type)
|
||||
),
|
||||
value.__class__.__name__.lower(),
|
||||
)
|
||||
try:
|
||||
converted: MySQLProducedType = getattr(self, f"_{type_name}_to_mysql")(
|
||||
value
|
||||
)
|
||||
return converted
|
||||
except AttributeError:
|
||||
# Value type is not a native one, nor a subclass of a native one
|
||||
if self.str_fallback:
|
||||
return str(value).encode()
|
||||
raise TypeError(
|
||||
f"Python '{type_name}' cannot be converted to a MySQL type"
|
||||
) from None
|
||||
|
||||
def to_python(
|
||||
self,
|
||||
vtype: DescriptionType,
|
||||
value: Optional[bytes],
|
||||
) -> PythonProducedType:
|
||||
"""Convert MySQL data type to Python"""
|
||||
# \x00
|
||||
if value == 0 and vtype[1] != FieldType.BIT:
|
||||
# Don't go further when we hit a NULL value
|
||||
return None
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if not self._cache_field_types:
|
||||
self._cache_field_types = {}
|
||||
for name, info in FieldType.desc.items():
|
||||
try:
|
||||
self._cache_field_types[info[0]] = getattr(
|
||||
self, f"_{name.lower()}_to_python"
|
||||
)
|
||||
except AttributeError:
|
||||
# We ignore field types which has no method
|
||||
pass
|
||||
|
||||
try:
|
||||
return self._cache_field_types[vtype[1]](value, vtype)
|
||||
except KeyError:
|
||||
# If one type is not defined, we just return the value as str
|
||||
try:
|
||||
return value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return value
|
||||
except ValueError as err:
|
||||
raise ValueError(f"{err} (field {vtype[0]})") from err
|
||||
except TypeError as err:
|
||||
raise TypeError(f"{err} (field {vtype[0]})") from err
|
||||
|
||||
@staticmethod
|
||||
def _int_to_mysql(value: int) -> int:
|
||||
"""Convert value to int"""
|
||||
return int(value)
|
||||
|
||||
@staticmethod
|
||||
def _long_to_mysql(value: int) -> int:
|
||||
"""Convert value to int
|
||||
|
||||
Note: There is no type "long" in Python 3 since integers are of unlimited size.
|
||||
Since Python 2 is no longer supported, this method should be deprecated.
|
||||
"""
|
||||
return int(value)
|
||||
|
||||
@staticmethod
|
||||
def _float_to_mysql(value: float) -> Optional[float]:
|
||||
"""Convert value to float"""
|
||||
if math.isnan(value):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
def _str_to_mysql(self, value: str) -> Union[bytes, HexLiteral]:
|
||||
"""Convert value to string"""
|
||||
return self._unicode_to_mysql(value)
|
||||
|
||||
def _unicode_to_mysql(self, value: str) -> Union[bytes, HexLiteral]:
|
||||
"""Convert unicode"""
|
||||
charset = self.charset
|
||||
charset_id = self.charset_id
|
||||
if charset == "binary":
|
||||
charset = "utf8"
|
||||
charset_id = self._character_set.get_charset_info(charset)[0]
|
||||
encoded = value.encode(charset)
|
||||
if charset_id in self._character_set.slash_charsets:
|
||||
if b"\x5c" in encoded:
|
||||
return HexLiteral(value, charset)
|
||||
return encoded
|
||||
|
||||
@staticmethod
|
||||
def _bytes_to_mysql(value: bytes) -> bytes:
|
||||
"""Convert value to bytes"""
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _bytearray_to_mysql(value: bytearray) -> bytes:
|
||||
"""Convert value to bytes"""
|
||||
return bytes(value)
|
||||
|
||||
@staticmethod
|
||||
def _bool_to_mysql(value: bool) -> int:
|
||||
"""Convert value to boolean"""
|
||||
return 1 if value else 0
|
||||
|
||||
@staticmethod
|
||||
def _nonetype_to_mysql(value: None) -> None: # pylint: disable=unused-argument
|
||||
"""
|
||||
This would return what None would be in MySQL, but instead we
|
||||
leave it None and return it right away. The actual conversion
|
||||
from None to NULL happens in the quoting functionality.
|
||||
|
||||
Return None.
|
||||
"""
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _datetime_to_mysql(value: datetime.datetime) -> bytes:
|
||||
"""
|
||||
Converts a datetime instance to a string suitable for MySQL.
|
||||
The returned string has format: %Y-%m-%d %H:%M:%S[.%f]
|
||||
|
||||
If the instance isn't a datetime.datetime type, it return None.
|
||||
|
||||
Returns a bytes.
|
||||
"""
|
||||
if value.microsecond:
|
||||
fmt = "{0:04d}-{1:02d}-{2:02d} {3:02d}:{4:02d}:{5:02d}.{6:06d}"
|
||||
return fmt.format(
|
||||
value.year,
|
||||
value.month,
|
||||
value.day,
|
||||
value.hour,
|
||||
value.minute,
|
||||
value.second,
|
||||
value.microsecond,
|
||||
).encode("ascii")
|
||||
|
||||
fmt = "{0:04d}-{1:02d}-{2:02d} {3:02d}:{4:02d}:{5:02d}"
|
||||
return fmt.format(
|
||||
value.year,
|
||||
value.month,
|
||||
value.day,
|
||||
value.hour,
|
||||
value.minute,
|
||||
value.second,
|
||||
).encode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def _date_to_mysql(value: datetime.date) -> bytes:
|
||||
"""
|
||||
Converts a date instance to a string suitable for MySQL.
|
||||
The returned string has format: %Y-%m-%d
|
||||
|
||||
If the instance isn't a datetime.date type, it return None.
|
||||
|
||||
Returns a bytes.
|
||||
"""
|
||||
return f"{value.year:04d}-{value.month:02d}-{value.day:02d}".encode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def _time_to_mysql(value: datetime.time) -> bytes:
|
||||
"""
|
||||
Converts a time instance to a string suitable for MySQL.
|
||||
The returned string has format: %H:%M:%S[.%f]
|
||||
|
||||
If the instance isn't a datetime.time type, it return None.
|
||||
|
||||
Returns a bytes.
|
||||
"""
|
||||
if value.microsecond:
|
||||
return value.strftime("%H:%M:%S.%f").encode("ascii")
|
||||
return value.strftime("%H:%M:%S").encode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def _struct_time_to_mysql(value: time.struct_time) -> bytes:
|
||||
"""
|
||||
Converts a time.struct_time sequence to a string suitable
|
||||
for MySQL.
|
||||
The returned string has format: %Y-%m-%d %H:%M:%S
|
||||
|
||||
Returns a bytes or None when not valid.
|
||||
"""
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", value).encode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def _timedelta_to_mysql(value: datetime.timedelta) -> bytes:
|
||||
"""
|
||||
Converts a timedelta instance to a string suitable for MySQL.
|
||||
The returned string has format: %H:%M:%S
|
||||
|
||||
Returns a bytes.
|
||||
"""
|
||||
seconds = abs(value.days * 86400 + value.seconds)
|
||||
|
||||
if value.microseconds:
|
||||
fmt = "{0:02d}:{1:02d}:{2:02d}.{3:06d}"
|
||||
if value.days < 0:
|
||||
mcs = 1000000 - value.microseconds
|
||||
seconds -= 1
|
||||
else:
|
||||
mcs = value.microseconds
|
||||
else:
|
||||
fmt = "{0:02d}:{1:02d}:{2:02d}"
|
||||
|
||||
if value.days < 0:
|
||||
fmt = "-" + fmt
|
||||
|
||||
(hours, remainder) = divmod(seconds, 3600)
|
||||
(mins, secs) = divmod(remainder, 60)
|
||||
|
||||
if value.microseconds:
|
||||
result = fmt.format(hours, mins, secs, mcs)
|
||||
else:
|
||||
result = fmt.format(hours, mins, secs)
|
||||
|
||||
return result.encode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def _decimal_to_mysql(value: Decimal) -> Optional[bytes]:
|
||||
"""
|
||||
Converts a decimal.Decimal instance to a string suitable for
|
||||
MySQL.
|
||||
|
||||
Returns a bytes or None when not valid.
|
||||
"""
|
||||
if isinstance(value, Decimal):
|
||||
return str(value).encode("ascii")
|
||||
|
||||
return None
|
||||
|
||||
def row_to_python(
|
||||
self, row: Tuple[bytes, ...], fields: List[DescriptionType]
|
||||
) -> Tuple[PythonProducedType, ...]:
|
||||
"""Convert a MySQL text result row to Python types
|
||||
|
||||
The row argument is a sequence containing text result returned
|
||||
by a MySQL server. Each value of the row is converted to the
|
||||
using the field type information in the fields argument.
|
||||
|
||||
Returns a tuple.
|
||||
"""
|
||||
i = 0
|
||||
result: List[PythonProducedType] = [None] * len(fields)
|
||||
|
||||
if not self._cache_field_types:
|
||||
self._cache_field_types = {}
|
||||
for name, info in FieldType.desc.items():
|
||||
try:
|
||||
self._cache_field_types[info[0]] = getattr(
|
||||
self, f"_{name.lower()}_to_python"
|
||||
)
|
||||
except AttributeError:
|
||||
# We ignore field types which has no method
|
||||
pass
|
||||
|
||||
for field in fields:
|
||||
field_type = field[1]
|
||||
|
||||
if (row[i] == 0 and field_type != FieldType.BIT) or row[i] is None:
|
||||
# Don't convert NULL value
|
||||
i += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
result[i] = self._cache_field_types[field_type](row[i], field)
|
||||
except KeyError:
|
||||
# If one type is not defined, we just return the value as str
|
||||
try:
|
||||
result[i] = row[i].decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
result[i] = row[i]
|
||||
except (ValueError, TypeError) as err:
|
||||
# Item "ValueError" of "Union[ValueError, TypeError]" has no attribute "message"
|
||||
err.message = f"{err} (field {field[0]})" # type: ignore[union-attr]
|
||||
raise
|
||||
|
||||
i += 1
|
||||
|
||||
return tuple(result)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@staticmethod
|
||||
def _float_to_python(value: bytes, desc: Optional[DescriptionType] = None) -> float:
|
||||
"""
|
||||
Returns value as float type.
|
||||
"""
|
||||
return float(value)
|
||||
|
||||
_double_to_python = _float_to_python
|
||||
|
||||
@staticmethod
|
||||
def _int_to_python(value: bytes, desc: Optional[DescriptionType] = None) -> int:
|
||||
"""
|
||||
Returns value as int type.
|
||||
"""
|
||||
return int(value)
|
||||
|
||||
_tiny_to_python = _int_to_python
|
||||
_short_to_python = _int_to_python
|
||||
_int24_to_python = _int_to_python
|
||||
_long_to_python = _int_to_python
|
||||
_longlong_to_python = _int_to_python
|
||||
|
||||
def _decimal_to_python(
|
||||
self, value: bytes, desc: Optional[DescriptionType] = None
|
||||
) -> Decimal:
|
||||
"""
|
||||
Returns value as a decimal.Decimal.
|
||||
"""
|
||||
val = value.decode(self.charset)
|
||||
return Decimal(val)
|
||||
|
||||
_newdecimal_to_python = _decimal_to_python
|
||||
|
||||
@staticmethod
|
||||
def _str(value: bytes, desc: Optional[DescriptionType] = None) -> str:
|
||||
"""
|
||||
Returns value as str type.
|
||||
"""
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _bit_to_python(value: bytes, dsc: Optional[DescriptionType] = None) -> int:
|
||||
"""Returns BIT columntype as integer"""
|
||||
int_val = value
|
||||
if len(int_val) < 8:
|
||||
int_val = b"\x00" * (8 - len(int_val)) + int_val
|
||||
return int(struct.unpack(">Q", int_val)[0])
|
||||
|
||||
@staticmethod
|
||||
def _date_to_python(
|
||||
value: bytes, dsc: Optional[DescriptionType] = None
|
||||
) -> Optional[datetime.date]:
|
||||
"""Converts TIME column MySQL to a python datetime.datetime type.
|
||||
|
||||
Raises ValueError if the value can not be converted.
|
||||
|
||||
Returns DATE column type as datetime.date type.
|
||||
"""
|
||||
if isinstance(value, datetime.date):
|
||||
return value
|
||||
try:
|
||||
parts = value.split(b"-")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"invalid datetime format: {parts} len: {len(parts)}")
|
||||
try:
|
||||
return datetime.date(int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
except ValueError:
|
||||
return None
|
||||
except (IndexError, ValueError):
|
||||
raise ValueError(
|
||||
f"Could not convert {repr(value)} to python datetime.timedelta"
|
||||
) from None
|
||||
|
||||
_NEWDATE_to_python = _date_to_python
|
||||
|
||||
@staticmethod
|
||||
def _time_to_python(
|
||||
value: bytes, dsc: Optional[DescriptionType] = None
|
||||
) -> datetime.timedelta:
|
||||
"""Converts TIME column value to python datetime.time value type.
|
||||
|
||||
Converts the TIME column MySQL type passed as bytes to a python
|
||||
datetime.datetime type.
|
||||
|
||||
Raises ValueError if the value can not be converted.
|
||||
|
||||
Returns datetime.timedelta type.
|
||||
"""
|
||||
mcs: Optional[Union[int, bytes]] = None
|
||||
try:
|
||||
(hms, mcs) = value.split(b".")
|
||||
mcs = int(mcs.ljust(6, b"0"))
|
||||
except (TypeError, ValueError):
|
||||
hms = value
|
||||
mcs = 0
|
||||
try:
|
||||
(hours, mins, secs) = [int(d) for d in hms.split(b":")]
|
||||
if value[0] == 45 or value[0] == "-":
|
||||
mins, secs, mcs = (
|
||||
-mins,
|
||||
-secs,
|
||||
-mcs, # pylint: disable=invalid-unary-operand-type
|
||||
)
|
||||
return datetime.timedelta(
|
||||
hours=hours, minutes=mins, seconds=secs, microseconds=mcs
|
||||
)
|
||||
except (IndexError, TypeError, ValueError):
|
||||
raise ValueError(
|
||||
CONVERT_ERROR.format(value=value, pytype="datetime.timedelta")
|
||||
) from None
|
||||
|
||||
@staticmethod
|
||||
def _datetime_to_python(
|
||||
value: bytes, dsc: Optional[DescriptionType] = None
|
||||
) -> Optional[datetime.datetime]:
|
||||
"""Converts DATETIME column value to python datetime.time value type.
|
||||
|
||||
Converts the DATETIME column MySQL type passed as bytes to a python
|
||||
datetime.datetime type.
|
||||
|
||||
Returns: datetime.datetime type.
|
||||
"""
|
||||
if isinstance(value, datetime.datetime):
|
||||
return value
|
||||
datetime_val = None
|
||||
mcs: Optional[Union[int, bytes]] = None
|
||||
try:
|
||||
(date_, time_) = value.split(b" ")
|
||||
if len(time_) > 8:
|
||||
(hms, mcs) = time_.split(b".")
|
||||
mcs = int(mcs.ljust(6, b"0"))
|
||||
else:
|
||||
hms = time_
|
||||
mcs = 0
|
||||
dtval = (
|
||||
[int(i) for i in date_.split(b"-")]
|
||||
+ [int(i) for i in hms.split(b":")]
|
||||
+ [
|
||||
mcs,
|
||||
]
|
||||
)
|
||||
if len(dtval) < 6:
|
||||
raise ValueError(f"invalid datetime format: {dtval} len: {len(dtval)}")
|
||||
# Note that by default MySQL accepts invalid timestamps
|
||||
# (this is also backward compatibility).
|
||||
# Traditionaly C/py returns None for this well formed but
|
||||
# invalid datetime for python like '0000-00-00 HH:MM:SS'.
|
||||
try:
|
||||
datetime_val = datetime.datetime(*dtval) # type: ignore[arg-type]
|
||||
except ValueError:
|
||||
return None
|
||||
except (IndexError, TypeError):
|
||||
raise ValueError(
|
||||
CONVERT_ERROR.format(value=value, pytype="datetime.timedelta")
|
||||
) from None
|
||||
|
||||
return datetime_val
|
||||
|
||||
_timestamp_to_python = _datetime_to_python
|
||||
|
||||
@staticmethod
|
||||
def _year_to_python(value: bytes, dsc: Optional[DescriptionType] = None) -> int:
|
||||
"""Returns YEAR column type as integer"""
|
||||
try:
|
||||
year = int(value)
|
||||
except ValueError as err:
|
||||
raise ValueError(f"Failed converting YEAR to int ({repr(value)})") from err
|
||||
|
||||
return year
|
||||
|
||||
def _set_to_python(
|
||||
self, value: bytes, dsc: Optional[DescriptionType] = None
|
||||
) -> Set[str]:
|
||||
"""Returns SET column type as set
|
||||
|
||||
Actually, MySQL protocol sees a SET as a string type field. So this
|
||||
code isn't called directly, but used by STRING_to_python() method.
|
||||
|
||||
Returns SET column type as a set.
|
||||
"""
|
||||
set_type = None
|
||||
val = value.decode(self.charset)
|
||||
if not val:
|
||||
return set()
|
||||
try:
|
||||
set_type = set(val.split(","))
|
||||
except ValueError as err:
|
||||
raise ValueError(
|
||||
f"Could not convert set {repr(value)} to a sequence"
|
||||
) from err
|
||||
return set_type
|
||||
|
||||
def _string_to_python(
|
||||
self, value: bytes, dsc: Optional[DescriptionType] = None
|
||||
) -> Union[StrOrBytes, Set[str]]:
|
||||
"""
|
||||
Note that a SET is a string too, but using the FieldFlag we can see
|
||||
whether we have to split it.
|
||||
|
||||
Returns string typed columns as string type.
|
||||
"""
|
||||
if self.charset == "binary":
|
||||
return value
|
||||
if dsc is not None:
|
||||
if dsc[1] == FieldType.JSON and self.use_unicode:
|
||||
return value.decode(self.charset)
|
||||
if dsc[7] & FieldFlag.SET:
|
||||
return self._set_to_python(value, dsc)
|
||||
# 'binary' charset
|
||||
if dsc[8] == 63:
|
||||
return value
|
||||
if isinstance(value, (bytes, bytearray)) and self.use_unicode:
|
||||
try:
|
||||
return value.decode(self.charset)
|
||||
except UnicodeDecodeError:
|
||||
return value
|
||||
|
||||
return value
|
||||
|
||||
_var_string_to_python = _string_to_python
|
||||
_json_to_python = _string_to_python
|
||||
|
||||
def _blob_to_python(
|
||||
self, value: bytes, dsc: Optional[DescriptionType] = None
|
||||
) -> Union[StrOrBytes, Set[str]]:
|
||||
"""Convert BLOB data type to Python."""
|
||||
if dsc is not None:
|
||||
if (
|
||||
dsc[7] & FieldFlag.BLOB
|
||||
and dsc[7] & FieldFlag.BINARY
|
||||
# 'binary' charset
|
||||
and dsc[8] == 63
|
||||
):
|
||||
return bytes(value)
|
||||
return self._string_to_python(value, dsc)
|
||||
|
||||
@staticmethod
|
||||
def _vector_to_python(
|
||||
value: Optional[bytes], desc: Optional[DescriptionType] = None
|
||||
) -> Optional[array.array]:
|
||||
"""
|
||||
Converts a MySQL VECTOR value to a Python array.array type.
|
||||
|
||||
Returns an array of floats if `value` isn't `None`, otherwise `None`.
|
||||
"""
|
||||
if value is None or isinstance(value, array.array):
|
||||
return value
|
||||
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return array.array(MYSQL_VECTOR_TYPE_CODE, value)
|
||||
|
||||
raise TypeError(f"Got unsupported type {value.__class__.__name__}")
|
||||
|
||||
_long_blob_to_python = _blob_to_python
|
||||
_medium_blob_to_python = _blob_to_python
|
||||
_tiny_blob_to_python = _blob_to_python
|
||||
# pylint: enable=unused-argument
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Custom Python types used by MySQL Connector/Python"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Type
|
||||
|
||||
|
||||
class HexLiteral(str):
|
||||
"""Class holding MySQL hex literals"""
|
||||
|
||||
charset: str = ""
|
||||
original: str = ""
|
||||
|
||||
def __new__(cls: Type[HexLiteral], str_: str, charset: str = "utf8") -> HexLiteral:
|
||||
hexed = [f"{i:02x}" for i in str_.encode(charset)]
|
||||
obj = str.__new__(cls, "".join(hexed))
|
||||
obj.charset = charset
|
||||
obj.original = str_
|
||||
return obj
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "0x" + self
|
||||
@@ -0,0 +1,92 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""
|
||||
This module implements some constructors and singletons as required by the
|
||||
DB API v2.0 (PEP-249).
|
||||
"""
|
||||
|
||||
# Python Db API v2
|
||||
# pylint: disable=invalid-name
|
||||
apilevel: str = "2.0"
|
||||
"""This attribute is a string that indicates the supported DB API level."""
|
||||
|
||||
threadsafety: int = 1
|
||||
"""This attribute is an integer that indicates the supported level of thread safety
|
||||
provided by Connector/Python."""
|
||||
|
||||
paramstyle: str = "pyformat"
|
||||
"""This attribute is a string that indicates the Connector/Python default
|
||||
parameter style."""
|
||||
|
||||
import datetime
|
||||
import time
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from . import constants
|
||||
|
||||
|
||||
class _DBAPITypeObject:
|
||||
def __init__(self, *values: int) -> None:
|
||||
self.values: Tuple[int, ...] = values
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return other in self.values
|
||||
|
||||
def __ne__(self, other: object) -> bool:
|
||||
return other not in self.values
|
||||
|
||||
|
||||
Date = datetime.date
|
||||
Time = datetime.time
|
||||
Timestamp = datetime.datetime
|
||||
|
||||
|
||||
def DateFromTicks(ticks: int) -> datetime.date:
|
||||
"""Construct an object holding a date value from the given ticks value."""
|
||||
return Date(*time.localtime(ticks)[:3])
|
||||
|
||||
|
||||
def TimeFromTicks(ticks: int) -> datetime.time:
|
||||
"""Construct an object holding a time value from the given ticks value."""
|
||||
return Time(*time.localtime(ticks)[3:6])
|
||||
|
||||
|
||||
def TimestampFromTicks(ticks: int) -> datetime.datetime:
|
||||
"""Construct an object holding a time stamp from the given ticks value."""
|
||||
return Timestamp(*time.localtime(ticks)[:6])
|
||||
|
||||
|
||||
Binary = bytes
|
||||
|
||||
STRING = _DBAPITypeObject(*constants.FieldType.get_string_types())
|
||||
BINARY = _DBAPITypeObject(*constants.FieldType.get_binary_types())
|
||||
NUMBER = _DBAPITypeObject(*constants.FieldType.get_number_types())
|
||||
DATETIME = _DBAPITypeObject(*constants.FieldType.get_timestamp_types())
|
||||
ROWID = _DBAPITypeObject()
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
@@ -0,0 +1,654 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="override"
|
||||
|
||||
"""Django database Backend using MySQL Connector/Python.
|
||||
|
||||
This Django database backend is heavily based on the MySQL backend from Django.
|
||||
|
||||
Changes include:
|
||||
* Support for microseconds (MySQL 5.6.3 and later)
|
||||
* Using INFORMATION_SCHEMA where possible
|
||||
* Using new defaults for, for example SQL_AUTO_IS_NULL
|
||||
|
||||
Requires and comes with MySQL Connector/Python v8.0.22 and later:
|
||||
http://dev.mysql.com/downloads/connector/python/
|
||||
"""
|
||||
|
||||
import warnings
|
||||
|
||||
from datetime import datetime, time
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
Generator,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import IntegrityError
|
||||
from django.db.backends.base.base import BaseDatabaseWrapper
|
||||
from django.utils import dateparse, timezone
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from mysql.connector.types import MySQLConvertibleType
|
||||
|
||||
try:
|
||||
import mysql.connector
|
||||
|
||||
from mysql.connector.conversion import MySQLConverter
|
||||
from mysql.connector.custom_types import HexLiteral
|
||||
from mysql.connector.pooling import PooledMySQLConnection
|
||||
from mysql.connector.types import ParamsSequenceOrDictType, RowType, StrOrBytes
|
||||
except ImportError as err:
|
||||
raise ImproperlyConfigured(f"Error loading mysql.connector module: {err}") from err
|
||||
|
||||
try:
|
||||
from _mysql_connector import datetime_to_mysql
|
||||
except ImportError:
|
||||
HAVE_CEXT = False
|
||||
else:
|
||||
HAVE_CEXT = True
|
||||
|
||||
from .client import DatabaseClient
|
||||
from .creation import DatabaseCreation
|
||||
from .features import DatabaseFeatures
|
||||
from .introspection import DatabaseIntrospection
|
||||
from .operations import DatabaseOperations
|
||||
from .schema import DatabaseSchemaEditor
|
||||
from .validation import DatabaseValidation
|
||||
|
||||
Error = mysql.connector.Error
|
||||
DatabaseError = mysql.connector.DatabaseError
|
||||
NotSupportedError = mysql.connector.NotSupportedError
|
||||
OperationalError = mysql.connector.OperationalError
|
||||
ProgrammingError = mysql.connector.ProgrammingError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract, MySQLCursorAbstract
|
||||
|
||||
|
||||
def adapt_datetime_with_timezone_support(value: datetime) -> StrOrBytes:
|
||||
"""Equivalent to DateTimeField.get_db_prep_value. Used only by raw SQL."""
|
||||
if settings.USE_TZ:
|
||||
if timezone.is_naive(value):
|
||||
warnings.warn(
|
||||
f"MySQL received a naive datetime ({value})"
|
||||
" while time zone support is active.",
|
||||
RuntimeWarning,
|
||||
)
|
||||
default_timezone = timezone.get_default_timezone()
|
||||
value = timezone.make_aware(value, default_timezone)
|
||||
# pylint: disable=no-member
|
||||
value = value.astimezone(timezone.utc).replace( # type: ignore[attr-defined]
|
||||
tzinfo=None
|
||||
)
|
||||
if HAVE_CEXT:
|
||||
mysql_datetime: bytes = datetime_to_mysql(value)
|
||||
return mysql_datetime
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
|
||||
|
||||
class CursorWrapper:
|
||||
"""Wrapper around MySQL Connector/Python's cursor class.
|
||||
|
||||
The cursor class is defined by the options passed to MySQL
|
||||
Connector/Python. If buffered option is True in those options,
|
||||
MySQLCursorBuffered will be used.
|
||||
"""
|
||||
|
||||
codes_for_integrityerror = (
|
||||
1048, # Column cannot be null
|
||||
1690, # BIGINT UNSIGNED value is out of range
|
||||
3819, # CHECK constraint is violated
|
||||
4025, # CHECK constraint failed
|
||||
)
|
||||
|
||||
def __init__(self, cursor: "MySQLCursorAbstract") -> None:
|
||||
self.cursor: "MySQLCursorAbstract" = cursor
|
||||
|
||||
@staticmethod
|
||||
def _adapt_execute_args_dict(
|
||||
args: Dict[str, MySQLConvertibleType],
|
||||
) -> Dict[str, MySQLConvertibleType]:
|
||||
if not args:
|
||||
return args
|
||||
new_args = dict(args)
|
||||
for key, value in args.items():
|
||||
if isinstance(value, datetime):
|
||||
new_args[key] = adapt_datetime_with_timezone_support(value)
|
||||
|
||||
return new_args
|
||||
|
||||
@staticmethod
|
||||
def _adapt_execute_args(
|
||||
args: Optional[Sequence[MySQLConvertibleType]],
|
||||
) -> Optional[Sequence[MySQLConvertibleType]]:
|
||||
if not args:
|
||||
return args
|
||||
new_args = list(args)
|
||||
for i, arg in enumerate(args):
|
||||
if isinstance(arg, datetime):
|
||||
new_args[i] = adapt_datetime_with_timezone_support(arg)
|
||||
|
||||
return tuple(new_args)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
query: str,
|
||||
args: Optional[
|
||||
Union[Sequence[MySQLConvertibleType], Dict[str, MySQLConvertibleType]]
|
||||
] = None,
|
||||
) -> Optional[Generator["MySQLCursorAbstract", None, None]]:
|
||||
"""Executes the given operation
|
||||
|
||||
This wrapper method around the execute()-method of the cursor is
|
||||
mainly needed to re-raise using different exceptions.
|
||||
"""
|
||||
new_args: Optional[ParamsSequenceOrDictType] = None
|
||||
if isinstance(args, dict):
|
||||
new_args = self._adapt_execute_args_dict(args)
|
||||
else:
|
||||
new_args = self._adapt_execute_args(args)
|
||||
try:
|
||||
return self.cursor.execute(query, new_args)
|
||||
except mysql.connector.OperationalError as exc:
|
||||
if exc.args[0] in self.codes_for_integrityerror:
|
||||
raise IntegrityError(*tuple(exc.args)) from None
|
||||
raise
|
||||
|
||||
def executemany(
|
||||
self,
|
||||
query: str,
|
||||
args: Sequence[
|
||||
Union[Sequence[MySQLConvertibleType], Dict[str, MySQLConvertibleType]]
|
||||
],
|
||||
) -> Optional[Generator["MySQLCursorAbstract", None, None]]:
|
||||
"""Executes the given operation
|
||||
|
||||
This wrapper method around the executemany()-method of the cursor is
|
||||
mainly needed to re-raise using different exceptions.
|
||||
"""
|
||||
try:
|
||||
return self.cursor.executemany(query, args)
|
||||
except mysql.connector.OperationalError as exc:
|
||||
if exc.args[0] in self.codes_for_integrityerror:
|
||||
raise IntegrityError(*tuple(exc.args)) from None
|
||||
raise
|
||||
|
||||
def __getattr__(self, attr: Any) -> Any:
|
||||
"""Return an attribute of wrapped cursor"""
|
||||
return getattr(self.cursor, attr)
|
||||
|
||||
def __iter__(self) -> Iterator[RowType]:
|
||||
"""Return an iterator over wrapped cursor"""
|
||||
return iter(self.cursor)
|
||||
|
||||
|
||||
class DatabaseWrapper(BaseDatabaseWrapper): # pylint: disable=abstract-method
|
||||
"""Represent a database connection."""
|
||||
|
||||
vendor = "mysql"
|
||||
# This dictionary maps Field objects to their associated MySQL column
|
||||
# types, as strings. Column-type strings can contain format strings; they'll
|
||||
# be interpolated against the values of Field.__dict__ before being output.
|
||||
# If a column type is set to None, it won't be included in the output.
|
||||
data_types = {
|
||||
"AutoField": "integer AUTO_INCREMENT",
|
||||
"BigAutoField": "bigint AUTO_INCREMENT",
|
||||
"BinaryField": "longblob",
|
||||
"BooleanField": "bool",
|
||||
"CharField": "varchar(%(max_length)s)",
|
||||
"DateField": "date",
|
||||
"DateTimeField": "datetime(6)",
|
||||
"DecimalField": "numeric(%(max_digits)s, %(decimal_places)s)",
|
||||
"DurationField": "bigint",
|
||||
"FileField": "varchar(%(max_length)s)",
|
||||
"FilePathField": "varchar(%(max_length)s)",
|
||||
"FloatField": "double precision",
|
||||
"IntegerField": "integer",
|
||||
"BigIntegerField": "bigint",
|
||||
"IPAddressField": "char(15)",
|
||||
"GenericIPAddressField": "char(39)",
|
||||
"JSONField": "json",
|
||||
"NullBooleanField": "bool",
|
||||
"OneToOneField": "integer",
|
||||
"PositiveBigIntegerField": "bigint UNSIGNED",
|
||||
"PositiveIntegerField": "integer UNSIGNED",
|
||||
"PositiveSmallIntegerField": "smallint UNSIGNED",
|
||||
"SlugField": "varchar(%(max_length)s)",
|
||||
"SmallAutoField": "smallint AUTO_INCREMENT",
|
||||
"SmallIntegerField": "smallint",
|
||||
"TextField": "longtext",
|
||||
"TimeField": "time(6)",
|
||||
"UUIDField": "char(32)",
|
||||
}
|
||||
|
||||
# For these data types:
|
||||
# - MySQL < 8.0.13 doesn't accept default values and
|
||||
# implicitly treat them as nullable
|
||||
# - all versions of MySQL doesn't support full width database
|
||||
# indexes
|
||||
_limited_data_types = (
|
||||
"tinyblob",
|
||||
"blob",
|
||||
"mediumblob",
|
||||
"longblob",
|
||||
"tinytext",
|
||||
"text",
|
||||
"mediumtext",
|
||||
"longtext",
|
||||
"json",
|
||||
)
|
||||
|
||||
operators = {
|
||||
"exact": "= %s",
|
||||
"iexact": "LIKE %s",
|
||||
"contains": "LIKE CAST(%s AS BINARY)",
|
||||
"icontains": "LIKE %s",
|
||||
"gt": "> %s",
|
||||
"gte": ">= %s",
|
||||
"lt": "< %s",
|
||||
"lte": "<= %s",
|
||||
"startswith": "LIKE CAST(%s AS BINARY)",
|
||||
"endswith": "LIKE CAST(%s AS BINARY)",
|
||||
"istartswith": "LIKE %s",
|
||||
"iendswith": "LIKE %s",
|
||||
}
|
||||
|
||||
# The patterns below are used to generate SQL pattern lookup clauses when
|
||||
# the right-hand side of the lookup isn't a raw string (it might be an expression
|
||||
# or the result of a bilateral transformation).
|
||||
# In those cases, special characters for LIKE operators (e.g. \, *, _) should be
|
||||
# escaped on database side.
|
||||
#
|
||||
# Note: we use str.format() here for readability as '%' is used as a wildcard for
|
||||
# the LIKE operator.
|
||||
pattern_esc = r"REPLACE(REPLACE(REPLACE({}, '\\', '\\\\'), '%%', '\%%'), '_', '\_')"
|
||||
pattern_ops = {
|
||||
"contains": "LIKE CAST(CONCAT('%%', {}, '%%') AS BINARY)",
|
||||
"icontains": "LIKE CONCAT('%%', {}, '%%')",
|
||||
"startswith": "LIKE CAST(CONCAT({}, '%%') AS BINARY)",
|
||||
"istartswith": "LIKE CONCAT({}, '%%')",
|
||||
"endswith": "LIKE CAST(CONCAT('%%', {}) AS BINARY)",
|
||||
"iendswith": "LIKE CONCAT('%%', {})",
|
||||
}
|
||||
|
||||
isolation_level: Optional[str] = None
|
||||
isolation_levels = {
|
||||
"read uncommitted",
|
||||
"read committed",
|
||||
"repeatable read",
|
||||
"serializable",
|
||||
}
|
||||
|
||||
Database = mysql.connector
|
||||
SchemaEditorClass = DatabaseSchemaEditor
|
||||
# Classes instantiated in __init__().
|
||||
client_class = DatabaseClient
|
||||
creation_class = DatabaseCreation
|
||||
features_class = DatabaseFeatures
|
||||
introspection_class = DatabaseIntrospection
|
||||
ops_class = DatabaseOperations
|
||||
validation_class = DatabaseValidation
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
options = self.settings_dict.get("OPTIONS")
|
||||
if options:
|
||||
self._use_pure = options.get("use_pure", not HAVE_CEXT)
|
||||
converter_class = options.get(
|
||||
"converter_class",
|
||||
DjangoMySQLConverter,
|
||||
)
|
||||
if not issubclass(converter_class, DjangoMySQLConverter):
|
||||
raise ProgrammingError(
|
||||
"Converter class should be a subclass of "
|
||||
"mysql.connector.django.base.DjangoMySQLConverter"
|
||||
)
|
||||
self.converter = converter_class()
|
||||
else:
|
||||
self.converter = DjangoMySQLConverter()
|
||||
self._use_pure = not HAVE_CEXT
|
||||
|
||||
def __getattr__(self, attr: str) -> bool:
|
||||
if attr.startswith("mysql_is"):
|
||||
return False
|
||||
raise AttributeError
|
||||
|
||||
def get_connection_params(self) -> Dict[str, Any]:
|
||||
kwargs: dict = {
|
||||
"consume_results": True,
|
||||
}
|
||||
|
||||
settings_dict = self.settings_dict
|
||||
|
||||
if settings_dict["USER"]:
|
||||
kwargs["user"] = settings_dict["USER"]
|
||||
if settings_dict["NAME"]:
|
||||
kwargs["database"] = settings_dict["NAME"]
|
||||
if settings_dict["PASSWORD"]:
|
||||
kwargs["passwd"] = settings_dict["PASSWORD"]
|
||||
if settings_dict["HOST"].startswith("/"):
|
||||
kwargs["unix_socket"] = settings_dict["HOST"]
|
||||
elif settings_dict["HOST"]:
|
||||
kwargs["host"] = settings_dict["HOST"]
|
||||
if settings_dict["PORT"]:
|
||||
kwargs["port"] = int(settings_dict["PORT"])
|
||||
if settings_dict.get("OPTIONS", {}).get("init_command"):
|
||||
kwargs["init_command"] = settings_dict["OPTIONS"]["init_command"]
|
||||
|
||||
# Raise exceptions for database warnings if DEBUG is on
|
||||
kwargs["raise_on_warnings"] = settings.DEBUG
|
||||
|
||||
kwargs["client_flags"] = [
|
||||
# Need potentially affected rows on UPDATE
|
||||
mysql.connector.constants.ClientFlag.FOUND_ROWS,
|
||||
]
|
||||
|
||||
try:
|
||||
options = settings_dict["OPTIONS"].copy()
|
||||
isolation_level = options.pop("isolation_level", None)
|
||||
if isolation_level:
|
||||
isolation_level = isolation_level.lower()
|
||||
if isolation_level not in self.isolation_levels:
|
||||
valid_levels = ", ".join(
|
||||
f"'{level}'" for level in sorted(self.isolation_levels)
|
||||
)
|
||||
raise ImproperlyConfigured(
|
||||
f"Invalid transaction isolation level '{isolation_level}' "
|
||||
f"specified.\nUse one of {valid_levels}, or None."
|
||||
)
|
||||
self.isolation_level = isolation_level
|
||||
kwargs.update(options)
|
||||
except KeyError:
|
||||
# OPTIONS missing is OK
|
||||
pass
|
||||
return kwargs
|
||||
|
||||
def get_new_connection(
|
||||
self, conn_params: Dict[str, Any]
|
||||
) -> Union[PooledMySQLConnection, "MySQLConnectionAbstract"]:
|
||||
if "converter_class" not in conn_params:
|
||||
conn_params["converter_class"] = DjangoMySQLConverter
|
||||
cnx = mysql.connector.connect(**conn_params)
|
||||
|
||||
return cnx
|
||||
|
||||
def init_connection_state(self) -> None:
|
||||
assignments = []
|
||||
if self.features.is_sql_auto_is_null_enabled: # type: ignore[attr-defined]
|
||||
# SQL_AUTO_IS_NULL controls whether an AUTO_INCREMENT column on
|
||||
# a recently inserted row will return when the field is tested
|
||||
# for NULL. Disabling this brings this aspect of MySQL in line
|
||||
# with SQL standards.
|
||||
assignments.append("SET SQL_AUTO_IS_NULL = 0")
|
||||
|
||||
if self.isolation_level:
|
||||
assignments.append(
|
||||
"SET SESSION TRANSACTION ISOLATION LEVEL "
|
||||
f"{self.isolation_level.upper()}"
|
||||
)
|
||||
|
||||
if assignments:
|
||||
with self.cursor() as cursor:
|
||||
cursor.execute("; ".join(assignments))
|
||||
|
||||
if "AUTOCOMMIT" in self.settings_dict:
|
||||
try:
|
||||
self.set_autocommit(self.settings_dict["AUTOCOMMIT"])
|
||||
except AttributeError:
|
||||
self._set_autocommit(self.settings_dict["AUTOCOMMIT"])
|
||||
|
||||
def create_cursor(self, name: Any = None) -> CursorWrapper:
|
||||
cursor = self.connection.cursor()
|
||||
return CursorWrapper(cursor)
|
||||
|
||||
def _rollback(self) -> None:
|
||||
try:
|
||||
BaseDatabaseWrapper._rollback(self) # type: ignore[attr-defined]
|
||||
except NotSupportedError:
|
||||
pass
|
||||
|
||||
def _set_autocommit(self, autocommit: bool) -> None:
|
||||
with self.wrap_database_errors:
|
||||
self.connection.autocommit = autocommit
|
||||
|
||||
def disable_constraint_checking(self) -> bool:
|
||||
"""
|
||||
Disable foreign key checks, primarily for use in adding rows with
|
||||
forward references. Always return True to indicate constraint checks
|
||||
need to be re-enabled.
|
||||
"""
|
||||
with self.cursor() as cursor:
|
||||
cursor.execute("SET foreign_key_checks=0")
|
||||
return True
|
||||
|
||||
def enable_constraint_checking(self) -> None:
|
||||
"""
|
||||
Re-enable foreign key checks after they have been disabled.
|
||||
"""
|
||||
# Override needs_rollback in case constraint_checks_disabled is
|
||||
# nested inside transaction.atomic.
|
||||
self.needs_rollback, needs_rollback = False, self.needs_rollback
|
||||
try:
|
||||
with self.cursor() as cursor:
|
||||
cursor.execute("SET foreign_key_checks=1")
|
||||
finally:
|
||||
self.needs_rollback = needs_rollback
|
||||
|
||||
def check_constraints(self, table_names: Optional[List[str]] = None) -> None:
|
||||
"""
|
||||
Check each table name in `table_names` for rows with invalid foreign
|
||||
key references. This method is intended to be used in conjunction with
|
||||
`disable_constraint_checking()` and `enable_constraint_checking()`, to
|
||||
determine if rows with invalid references were entered while constraint
|
||||
checks were off.
|
||||
"""
|
||||
with self.cursor() as cursor:
|
||||
if table_names is None:
|
||||
table_names = self.introspection.table_names(cursor)
|
||||
for table_name in table_names:
|
||||
primary_key_column_name = self.introspection.get_primary_key_column(
|
||||
cursor, table_name
|
||||
)
|
||||
if not primary_key_column_name:
|
||||
continue
|
||||
key_columns = self.introspection.get_key_columns( # type: ignore[attr-defined]
|
||||
cursor, table_name
|
||||
)
|
||||
for (
|
||||
column_name,
|
||||
referenced_table_name,
|
||||
referenced_column_name,
|
||||
) in key_columns:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT REFERRING.`{primary_key_column_name}`,
|
||||
REFERRING.`{column_name}`
|
||||
FROM `{table_name}` as REFERRING
|
||||
LEFT JOIN `{referenced_table_name}` as REFERRED
|
||||
ON (
|
||||
REFERRING.`{column_name}` =
|
||||
REFERRED.`{referenced_column_name}`
|
||||
)
|
||||
WHERE REFERRING.`{column_name}` IS NOT NULL
|
||||
AND REFERRED.`{referenced_column_name}` IS NULL
|
||||
"""
|
||||
)
|
||||
for bad_row in cursor.fetchall():
|
||||
raise IntegrityError(
|
||||
f"The row in table '{table_name}' with primary "
|
||||
f"key '{bad_row[0]}' has an invalid foreign key: "
|
||||
f"{table_name}.{column_name} contains a value "
|
||||
f"'{bad_row[1]}' that does not have a "
|
||||
f"corresponding value in "
|
||||
f"{referenced_table_name}."
|
||||
f"{referenced_column_name}."
|
||||
)
|
||||
|
||||
def is_usable(self) -> bool:
|
||||
try:
|
||||
self.connection.ping()
|
||||
except Error:
|
||||
return False
|
||||
return True
|
||||
|
||||
@cached_property # type: ignore[misc]
|
||||
@staticmethod
|
||||
def display_name() -> str:
|
||||
"""Display name."""
|
||||
return "MySQL"
|
||||
|
||||
@cached_property
|
||||
def data_type_check_constraints(self) -> Dict[str, str]:
|
||||
"""Mapping of Field objects to their SQL for CHECK constraints."""
|
||||
if self.features.supports_column_check_constraints:
|
||||
check_constraints = {
|
||||
"PositiveBigIntegerField": "`%(column)s` >= 0",
|
||||
"PositiveIntegerField": "`%(column)s` >= 0",
|
||||
"PositiveSmallIntegerField": "`%(column)s` >= 0",
|
||||
}
|
||||
return check_constraints
|
||||
return {}
|
||||
|
||||
@cached_property
|
||||
def mysql_server_data(self) -> Dict[str, Any]:
|
||||
"""Return MySQL server data."""
|
||||
with self.temporary_connection() as cursor:
|
||||
# Select some server variables and test if the time zone
|
||||
# definitions are installed. CONVERT_TZ returns NULL if 'UTC'
|
||||
# timezone isn't loaded into the mysql.time_zone table.
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT VERSION(),
|
||||
@@sql_mode,
|
||||
@@default_storage_engine,
|
||||
@@sql_auto_is_null,
|
||||
@@lower_case_table_names,
|
||||
CONVERT_TZ('2001-01-01 01:00:00', 'UTC', 'UTC') IS NOT NULL
|
||||
"""
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return {
|
||||
"version": row[0],
|
||||
"sql_mode": row[1],
|
||||
"default_storage_engine": row[2],
|
||||
"sql_auto_is_null": bool(row[3]),
|
||||
"lower_case_table_names": bool(row[4]),
|
||||
"has_zoneinfo_database": bool(row[5]),
|
||||
}
|
||||
|
||||
@cached_property
|
||||
def mysql_server_info(self) -> Any:
|
||||
"""Return MySQL version."""
|
||||
if self.connection:
|
||||
return self.connection.server_info
|
||||
with self.temporary_connection() as cursor:
|
||||
cursor.execute("SELECT VERSION()")
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
@cached_property
|
||||
def mysql_version(self) -> Tuple[int, ...]:
|
||||
"""Return MySQL version."""
|
||||
if self.connection:
|
||||
return self.connection.server_version
|
||||
config = self.get_connection_params()
|
||||
with mysql.connector.connect(**config) as conn:
|
||||
server_version: Tuple[int, ...] = conn.server_version
|
||||
return server_version
|
||||
|
||||
@cached_property
|
||||
def sql_mode(self) -> Set[str]:
|
||||
"""Return SQL mode."""
|
||||
if self.connection:
|
||||
return set(self.connection.sql_mode.split(","))
|
||||
with self.cursor() as cursor:
|
||||
cursor.execute("SELECT @@sql_mode")
|
||||
sql_mode = cursor.fetchone()
|
||||
return set(sql_mode[0].split(",") if sql_mode else ())
|
||||
|
||||
@property
|
||||
def use_pure(self) -> bool:
|
||||
"""Return True if pure Python version is being used."""
|
||||
ans: bool = self._use_pure
|
||||
return ans
|
||||
|
||||
|
||||
class DjangoMySQLConverter(MySQLConverter):
|
||||
"""Custom converter for Django."""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
@staticmethod
|
||||
def _time_to_python(value: bytes, dsc: Any = None) -> Optional[time]:
|
||||
"""Return MySQL TIME data type as datetime.time()
|
||||
|
||||
Returns datetime.time()
|
||||
"""
|
||||
return dateparse.parse_time(value.decode("utf-8"))
|
||||
|
||||
@staticmethod
|
||||
def _datetime_to_python(value: bytes, dsc: Any = None) -> Optional[datetime]:
|
||||
"""Connector/Python always returns naive datetime.datetime
|
||||
|
||||
Connector/Python always returns naive timestamps since MySQL has
|
||||
no time zone support.
|
||||
|
||||
- A naive datetime is a datetime that doesn't know its own timezone.
|
||||
|
||||
Django needs a non-naive datetime, but in this method we don't need
|
||||
to make a datetime value time zone aware since Django itself at some
|
||||
point will make it aware (at least in versions 3.2.16 and 4.1.2) when
|
||||
USE_TZ=True. This may change in a future release, we need to keep an
|
||||
eye on this behaviour.
|
||||
|
||||
Returns datetime.datetime()
|
||||
"""
|
||||
return MySQLConverter._datetime_to_python(value) if value else None
|
||||
|
||||
# pylint: enable=unused-argument
|
||||
|
||||
def _safestring_to_mysql(self, value: str) -> Union[bytes, HexLiteral]:
|
||||
return self._str_to_mysql(value)
|
||||
|
||||
def _safetext_to_mysql(self, value: str) -> Union[bytes, HexLiteral]:
|
||||
return self._str_to_mysql(value)
|
||||
|
||||
def _safebytes_to_mysql(self, value: bytes) -> bytes:
|
||||
return self._bytes_to_mysql(value)
|
||||
@@ -0,0 +1,106 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Database Client."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from django.db.backends.base.client import BaseDatabaseClient
|
||||
|
||||
|
||||
class DatabaseClient(BaseDatabaseClient):
|
||||
"""Encapsulate backend-specific methods for opening a client shell."""
|
||||
|
||||
executable_name = "mysql"
|
||||
|
||||
@classmethod
|
||||
def settings_to_cmd_args_env(
|
||||
cls, settings_dict: Dict[str, Any], parameters: Optional[Iterable[str]] = None
|
||||
) -> Tuple[List[str], Optional[Dict[str, Any]]]:
|
||||
args = [cls.executable_name]
|
||||
|
||||
db = settings_dict["OPTIONS"].get("database", settings_dict["NAME"])
|
||||
user = settings_dict["OPTIONS"].get("user", settings_dict["USER"])
|
||||
passwd = settings_dict["OPTIONS"].get("password", settings_dict["PASSWORD"])
|
||||
host = settings_dict["OPTIONS"].get("host", settings_dict["HOST"])
|
||||
port = settings_dict["OPTIONS"].get("port", settings_dict["PORT"])
|
||||
ssl_ca = settings_dict["OPTIONS"].get("ssl_ca")
|
||||
ssl_cert = settings_dict["OPTIONS"].get("ssl_cert")
|
||||
ssl_key = settings_dict["OPTIONS"].get("ssl_key")
|
||||
defaults_file = settings_dict["OPTIONS"].get("read_default_file")
|
||||
charset = settings_dict["OPTIONS"].get("charset")
|
||||
|
||||
# --defaults-file should always be the first option
|
||||
if defaults_file:
|
||||
args.append(f"--defaults-file={defaults_file}")
|
||||
|
||||
# Load any custom init_commands. We always force SQL_MODE to TRADITIONAL
|
||||
init_command = settings_dict["OPTIONS"].get("init_command", "")
|
||||
args.append(f"--init-command=SET @@session.SQL_MODE=TRADITIONAL;{init_command}")
|
||||
|
||||
if user:
|
||||
args.append(f"--user={user}")
|
||||
if passwd:
|
||||
args.append(f"--password={passwd}")
|
||||
|
||||
if host:
|
||||
if "/" in host:
|
||||
args.append(f"--socket={host}")
|
||||
else:
|
||||
args.append(f"--host={host}")
|
||||
|
||||
if port:
|
||||
args.append(f"--port={port}")
|
||||
|
||||
if db:
|
||||
args.append(f"--database={db}")
|
||||
|
||||
if ssl_ca:
|
||||
args.append(f"--ssl-ca={ssl_ca}")
|
||||
if ssl_cert:
|
||||
args.append(f"--ssl-cert={ssl_cert}")
|
||||
if ssl_key:
|
||||
args.append(f"--ssl-key={ssl_key}")
|
||||
|
||||
if charset:
|
||||
args.append(f"--default-character-set={charset}")
|
||||
|
||||
if parameters:
|
||||
args.extend(parameters)
|
||||
|
||||
return args, None
|
||||
|
||||
def runshell(self, parameters: Optional[Iterable[str]] = None) -> None:
|
||||
args, env = self.settings_to_cmd_args_env(
|
||||
self.connection.settings_dict, parameters
|
||||
)
|
||||
env = {**os.environ, **env} if env else None
|
||||
subprocess.run(args, env=env, check=True)
|
||||
@@ -0,0 +1,45 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""SQL Compiler classes."""
|
||||
|
||||
from django.db.backends.mysql.compiler import (
|
||||
SQLAggregateCompiler,
|
||||
SQLCompiler,
|
||||
SQLDeleteCompiler,
|
||||
SQLInsertCompiler,
|
||||
SQLUpdateCompiler,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SQLAggregateCompiler",
|
||||
"SQLCompiler",
|
||||
"SQLDeleteCompiler",
|
||||
"SQLInsertCompiler",
|
||||
"SQLUpdateCompiler",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Backend specific database creation."""
|
||||
|
||||
from django.db.backends.mysql.creation import DatabaseCreation
|
||||
|
||||
__all__ = ["DatabaseCreation"]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Database Features."""
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from django.db.backends.mysql.features import DatabaseFeatures as MySQLDatabaseFeatures
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
|
||||
class DatabaseFeatures(MySQLDatabaseFeatures):
|
||||
"""Database Features Specification class."""
|
||||
|
||||
empty_fetchmany_value: List[Any] = []
|
||||
|
||||
@cached_property
|
||||
def can_introspect_check_constraints(self) -> bool: # type: ignore[override]
|
||||
"""Check if backend support introspection CHECK of constraints."""
|
||||
return self.connection.mysql_version >= (8, 0, 16)
|
||||
|
||||
@cached_property
|
||||
def supports_microsecond_precision(self) -> bool:
|
||||
"""Check if backend support microsecond precision."""
|
||||
return self.connection.mysql_version >= (5, 6, 3)
|
||||
@@ -0,0 +1,461 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="override,attr-defined,call-arg"
|
||||
|
||||
"""Database Introspection."""
|
||||
|
||||
from collections import namedtuple
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import sqlparse
|
||||
|
||||
from django import VERSION as DJANGO_VERSION
|
||||
from django.db.backends.base.introspection import (
|
||||
BaseDatabaseIntrospection,
|
||||
FieldInfo as BaseFieldInfo,
|
||||
TableInfo,
|
||||
)
|
||||
from django.db.models import Index
|
||||
from django.utils.datastructures import OrderedSet
|
||||
|
||||
from mysql.connector.constants import FieldType
|
||||
|
||||
# from .base import CursorWrapper produces a circular import error,
|
||||
# avoiding importing CursorWrapper explicitly, using a documented
|
||||
# trick; write the imports inside if TYPE_CHECKING: so that they
|
||||
# are not executed at runtime.
|
||||
# Ref: https://buildmedia.readthedocs.org/media/pdf/mypy/stable/mypy.pdf [page 42]
|
||||
if TYPE_CHECKING:
|
||||
# CursorWraper is used exclusively for type hinting
|
||||
from mysql.connector.django.base import CursorWrapper
|
||||
|
||||
# Based on my investigation, named tuples to
|
||||
# comply with mypy need to define a static list or tuple
|
||||
# for field_names (second argument). In this case, the field
|
||||
# names are created dynamically for FieldInfo which triggers
|
||||
# a mypy error. The solution is not straightforward since
|
||||
# FieldInfo attributes are Django version dependent. Code
|
||||
# refactory is needed to fix this issue.
|
||||
FieldInfo = namedtuple( # type: ignore[misc]
|
||||
"FieldInfo",
|
||||
BaseFieldInfo._fields + ("extra", "is_unsigned", "has_json_constraint"),
|
||||
)
|
||||
if DJANGO_VERSION < (3, 2, 0):
|
||||
InfoLine = namedtuple(
|
||||
"InfoLine",
|
||||
"col_name data_type max_len num_prec num_scale extra column_default "
|
||||
"is_unsigned",
|
||||
)
|
||||
else:
|
||||
InfoLine = namedtuple( # type: ignore[no-redef]
|
||||
"InfoLine",
|
||||
"col_name data_type max_len num_prec num_scale extra column_default "
|
||||
"collation is_unsigned",
|
||||
)
|
||||
|
||||
|
||||
class DatabaseIntrospection(BaseDatabaseIntrospection):
|
||||
"""Encapsulate backend-specific introspection utilities."""
|
||||
|
||||
data_types_reverse = {
|
||||
FieldType.BLOB: "TextField",
|
||||
FieldType.DECIMAL: "DecimalField",
|
||||
FieldType.NEWDECIMAL: "DecimalField",
|
||||
FieldType.DATE: "DateField",
|
||||
FieldType.DATETIME: "DateTimeField",
|
||||
FieldType.DOUBLE: "FloatField",
|
||||
FieldType.FLOAT: "FloatField",
|
||||
FieldType.INT24: "IntegerField",
|
||||
FieldType.LONG: "IntegerField",
|
||||
FieldType.LONGLONG: "BigIntegerField",
|
||||
FieldType.SHORT: "SmallIntegerField",
|
||||
FieldType.STRING: "CharField",
|
||||
FieldType.TIME: "TimeField",
|
||||
FieldType.TIMESTAMP: "DateTimeField",
|
||||
FieldType.TINY: "IntegerField",
|
||||
FieldType.TINY_BLOB: "TextField",
|
||||
FieldType.MEDIUM_BLOB: "TextField",
|
||||
FieldType.LONG_BLOB: "TextField",
|
||||
FieldType.VAR_STRING: "CharField",
|
||||
}
|
||||
|
||||
def get_field_type(self, data_type: str, description: FieldInfo) -> str:
|
||||
field_type = super().get_field_type(data_type, description) # type: ignore[arg-type]
|
||||
if "auto_increment" in description.extra:
|
||||
if field_type == "IntegerField":
|
||||
return "AutoField"
|
||||
if field_type == "BigIntegerField":
|
||||
return "BigAutoField"
|
||||
if field_type == "SmallIntegerField":
|
||||
return "SmallAutoField"
|
||||
if description.is_unsigned:
|
||||
if field_type == "BigIntegerField":
|
||||
return "PositiveBigIntegerField"
|
||||
if field_type == "IntegerField":
|
||||
return "PositiveIntegerField"
|
||||
if field_type == "SmallIntegerField":
|
||||
return "PositiveSmallIntegerField"
|
||||
# JSON data type is an alias for LONGTEXT in MariaDB, use check
|
||||
# constraints clauses to introspect JSONField.
|
||||
if description.has_json_constraint:
|
||||
return "JSONField"
|
||||
return field_type
|
||||
|
||||
def get_table_list(self, cursor: "CursorWrapper") -> List[TableInfo]:
|
||||
"""Return a list of table and view names in the current database."""
|
||||
cursor.execute("SHOW FULL TABLES")
|
||||
return [
|
||||
TableInfo(row[0], {"BASE TABLE": "t", "VIEW": "v"}.get(row[1]))
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
def get_table_description(
|
||||
self, cursor: "CursorWrapper", table_name: str
|
||||
) -> List[FieldInfo]:
|
||||
"""
|
||||
Return a description of the table with the DB-API cursor.description
|
||||
interface."
|
||||
"""
|
||||
json_constraints: Dict[Any, Any] = {}
|
||||
# A default collation for the given table.
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT table_collation
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = %s
|
||||
""",
|
||||
[table_name],
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
default_column_collation = row[0] if row else ""
|
||||
# information_schema database gives more accurate results for some figures:
|
||||
# - varchar length returned by cursor.description is an internal length,
|
||||
# not visible length (#5725)
|
||||
# - precision and scale (for decimal fields) (#5014)
|
||||
# - auto_increment is not available in cursor.description
|
||||
if DJANGO_VERSION < (3, 2, 0):
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
column_name, data_type, character_maximum_length,
|
||||
numeric_precision, numeric_scale, extra, column_default,
|
||||
CASE
|
||||
WHEN column_type LIKE '%% unsigned' THEN 1
|
||||
ELSE 0
|
||||
END AS is_unsigned
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = %s AND table_schema = DATABASE()
|
||||
""",
|
||||
[table_name],
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
column_name, data_type, character_maximum_length,
|
||||
numeric_precision, numeric_scale, extra, column_default,
|
||||
CASE
|
||||
WHEN collation_name = %s THEN NULL
|
||||
ELSE collation_name
|
||||
END AS collation_name,
|
||||
CASE
|
||||
WHEN column_type LIKE '%% unsigned' THEN 1
|
||||
ELSE 0
|
||||
END AS is_unsigned
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = %s AND table_schema = DATABASE()
|
||||
""",
|
||||
[default_column_collation, table_name],
|
||||
)
|
||||
field_info = {line[0]: InfoLine(*line) for line in cursor.fetchall()}
|
||||
|
||||
cursor.execute(
|
||||
f"SELECT * FROM {self.connection.ops.quote_name(table_name)} LIMIT 1"
|
||||
)
|
||||
|
||||
def to_int(i: Any) -> Optional[int]:
|
||||
return int(i) if i is not None else i
|
||||
|
||||
fields = []
|
||||
for line in cursor.description:
|
||||
info = field_info[line[0]]
|
||||
if DJANGO_VERSION < (3, 2, 0):
|
||||
fields.append(
|
||||
FieldInfo(
|
||||
*line[:3],
|
||||
to_int(info.max_len) or line[3],
|
||||
to_int(info.num_prec) or line[4],
|
||||
to_int(info.num_scale) or line[5],
|
||||
line[6],
|
||||
info.column_default,
|
||||
info.extra,
|
||||
info.is_unsigned,
|
||||
line[0] in json_constraints,
|
||||
)
|
||||
)
|
||||
else:
|
||||
fields.append(
|
||||
FieldInfo(
|
||||
*line[:3],
|
||||
to_int(info.max_len) or line[3],
|
||||
to_int(info.num_prec) or line[4],
|
||||
to_int(info.num_scale) or line[5],
|
||||
line[6],
|
||||
info.column_default,
|
||||
info.collation,
|
||||
info.extra,
|
||||
info.is_unsigned,
|
||||
line[0] in json_constraints,
|
||||
)
|
||||
)
|
||||
return fields
|
||||
|
||||
def get_indexes(
|
||||
self, cursor: "CursorWrapper", table_name: str
|
||||
) -> Dict[int, Dict[str, bool]]:
|
||||
"""Return indexes from table."""
|
||||
cursor.execute(f"SHOW INDEX FROM {self.connection.ops.quote_name(table_name)}")
|
||||
# Do a two-pass search for indexes: on first pass check which indexes
|
||||
# are multicolumn, on second pass check which single-column indexes
|
||||
# are present.
|
||||
rows = list(cursor.fetchall())
|
||||
multicol_indexes = set()
|
||||
for row in rows:
|
||||
if row[3] > 1:
|
||||
multicol_indexes.add(row[2])
|
||||
indexes: Dict[int, Dict[str, bool]] = {}
|
||||
for row in rows:
|
||||
if row[2] in multicol_indexes:
|
||||
continue
|
||||
if row[4] not in indexes:
|
||||
indexes[row[4]] = {"primary_key": False, "unique": False}
|
||||
# It's possible to have the unique and PK constraints in
|
||||
# separate indexes.
|
||||
if row[2] == "PRIMARY":
|
||||
indexes[row[4]]["primary_key"] = True
|
||||
if not row[1]:
|
||||
indexes[row[4]]["unique"] = True
|
||||
return indexes
|
||||
|
||||
def get_primary_key_column(
|
||||
self, cursor: "CursorWrapper", table_name: str
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Returns the name of the primary key column for the given table
|
||||
"""
|
||||
for column in self.get_indexes(cursor, table_name).items():
|
||||
if column[1]["primary_key"]:
|
||||
return column[0]
|
||||
return None
|
||||
|
||||
def get_sequences(
|
||||
self, cursor: "CursorWrapper", table_name: str, table_fields: Any = ()
|
||||
) -> List[Dict[str, str]]:
|
||||
for field_info in self.get_table_description(cursor, table_name):
|
||||
if "auto_increment" in field_info.extra:
|
||||
# MySQL allows only one auto-increment column per table.
|
||||
return [{"table": table_name, "column": field_info.name}]
|
||||
return []
|
||||
|
||||
def get_relations(
|
||||
self, cursor: "CursorWrapper", table_name: str
|
||||
) -> Dict[str, Tuple[str, str]]:
|
||||
"""
|
||||
Return a dictionary of {field_name: (field_name_other_table, other_table)}
|
||||
representing all relationships to the given table.
|
||||
"""
|
||||
constraints = self.get_key_columns(cursor, table_name)
|
||||
relations = {}
|
||||
for my_fieldname, other_table, other_field in constraints:
|
||||
relations[my_fieldname] = (other_field, other_table)
|
||||
return relations
|
||||
|
||||
def get_key_columns(
|
||||
self, cursor: "CursorWrapper", table_name: str
|
||||
) -> List[Tuple[str, str, str]]:
|
||||
"""
|
||||
Return a list of (column_name, referenced_table_name, referenced_column_name)
|
||||
for all key columns in the given table.
|
||||
"""
|
||||
key_columns: List[Any] = []
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT column_name, referenced_table_name, referenced_column_name
|
||||
FROM information_schema.key_column_usage
|
||||
WHERE table_name = %s
|
||||
AND table_schema = DATABASE()
|
||||
AND referenced_table_name IS NOT NULL
|
||||
AND referenced_column_name IS NOT NULL""",
|
||||
[table_name],
|
||||
)
|
||||
key_columns.extend(cursor.fetchall())
|
||||
return key_columns
|
||||
|
||||
def get_storage_engine(self, cursor: "CursorWrapper", table_name: str) -> str:
|
||||
"""
|
||||
Retrieve the storage engine for a given table. Return the default
|
||||
storage engine if the table doesn't exist.
|
||||
"""
|
||||
cursor.execute(
|
||||
"SELECT engine FROM information_schema.tables WHERE table_name = %s",
|
||||
[table_name],
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
# pylint: disable=protected-access
|
||||
if not result:
|
||||
return self.connection.features._mysql_storage_engine
|
||||
# pylint: enable=protected-access
|
||||
return result[0]
|
||||
|
||||
def _parse_constraint_columns(
|
||||
self, check_clause: Any, columns: Set[str]
|
||||
) -> OrderedSet:
|
||||
check_columns: OrderedSet = OrderedSet()
|
||||
statement = sqlparse.parse(check_clause)[0]
|
||||
tokens = (token for token in statement.flatten() if not token.is_whitespace)
|
||||
for token in tokens:
|
||||
if (
|
||||
token.ttype == sqlparse.tokens.Name
|
||||
and self.connection.ops.quote_name(token.value) == token.value
|
||||
and token.value[1:-1] in columns
|
||||
):
|
||||
check_columns.add(token.value[1:-1])
|
||||
return check_columns
|
||||
|
||||
def get_constraints(
|
||||
self, cursor: "CursorWrapper", table_name: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve any constraints or keys (unique, pk, fk, check, index) across
|
||||
one or more columns.
|
||||
"""
|
||||
constraints: Dict[str, Any] = {}
|
||||
# Get the actual constraint names and columns
|
||||
name_query = """
|
||||
SELECT kc.`constraint_name`, kc.`column_name`,
|
||||
kc.`referenced_table_name`, kc.`referenced_column_name`
|
||||
FROM information_schema.key_column_usage AS kc
|
||||
WHERE
|
||||
kc.table_schema = DATABASE() AND
|
||||
kc.table_name = %s
|
||||
ORDER BY kc.`ordinal_position`
|
||||
"""
|
||||
cursor.execute(name_query, [table_name])
|
||||
for constraint, column, ref_table, ref_column in cursor.fetchall():
|
||||
if constraint not in constraints:
|
||||
constraints[constraint] = {
|
||||
"columns": OrderedSet(),
|
||||
"primary_key": False,
|
||||
"unique": False,
|
||||
"index": False,
|
||||
"check": False,
|
||||
"foreign_key": (ref_table, ref_column) if ref_column else None,
|
||||
}
|
||||
if self.connection.features.supports_index_column_ordering:
|
||||
constraints[constraint]["orders"] = []
|
||||
constraints[constraint]["columns"].add(column)
|
||||
# Now get the constraint types
|
||||
type_query = """
|
||||
SELECT c.constraint_name, c.constraint_type
|
||||
FROM information_schema.table_constraints AS c
|
||||
WHERE
|
||||
c.table_schema = DATABASE() AND
|
||||
c.table_name = %s
|
||||
"""
|
||||
cursor.execute(type_query, [table_name])
|
||||
for constraint, kind in cursor.fetchall():
|
||||
if kind.lower() == "primary key":
|
||||
constraints[constraint]["primary_key"] = True
|
||||
constraints[constraint]["unique"] = True
|
||||
elif kind.lower() == "unique":
|
||||
constraints[constraint]["unique"] = True
|
||||
# Add check constraints.
|
||||
if self.connection.features.can_introspect_check_constraints:
|
||||
unnamed_constraints_index = 0
|
||||
columns = {
|
||||
info.name for info in self.get_table_description(cursor, table_name)
|
||||
}
|
||||
type_query = """
|
||||
SELECT cc.constraint_name, cc.check_clause
|
||||
FROM
|
||||
information_schema.check_constraints AS cc,
|
||||
information_schema.table_constraints AS tc
|
||||
WHERE
|
||||
cc.constraint_schema = DATABASE() AND
|
||||
tc.table_schema = cc.constraint_schema AND
|
||||
cc.constraint_name = tc.constraint_name AND
|
||||
tc.constraint_type = 'CHECK' AND
|
||||
tc.table_name = %s
|
||||
"""
|
||||
cursor.execute(type_query, [table_name])
|
||||
for constraint, check_clause in cursor.fetchall():
|
||||
constraint_columns = self._parse_constraint_columns(
|
||||
check_clause, columns
|
||||
)
|
||||
# Ensure uniqueness of unnamed constraints. Unnamed unique
|
||||
# and check columns constraints have the same name as
|
||||
# a column.
|
||||
if set(constraint_columns) == {constraint}:
|
||||
unnamed_constraints_index += 1
|
||||
constraint = f"__unnamed_constraint_{unnamed_constraints_index}__"
|
||||
constraints[constraint] = {
|
||||
"columns": constraint_columns,
|
||||
"primary_key": False,
|
||||
"unique": False,
|
||||
"index": False,
|
||||
"check": True,
|
||||
"foreign_key": None,
|
||||
}
|
||||
# Now add in the indexes
|
||||
cursor.execute(f"SHOW INDEX FROM {self.connection.ops.quote_name(table_name)}")
|
||||
for _, _, index, _, column, order, type_ in [
|
||||
x[:6] + (x[10],) for x in cursor.fetchall()
|
||||
]:
|
||||
if index not in constraints:
|
||||
constraints[index] = {
|
||||
"columns": OrderedSet(),
|
||||
"primary_key": False,
|
||||
"unique": False,
|
||||
"check": False,
|
||||
"foreign_key": None,
|
||||
}
|
||||
if self.connection.features.supports_index_column_ordering:
|
||||
constraints[index]["orders"] = []
|
||||
constraints[index]["index"] = True
|
||||
constraints[index]["type"] = (
|
||||
Index.suffix if type_ == "BTREE" else type_.lower()
|
||||
)
|
||||
constraints[index]["columns"].add(column)
|
||||
if self.connection.features.supports_index_column_ordering:
|
||||
constraints[index]["orders"].append("DESC" if order == "D" else "ASC")
|
||||
# Convert the sorted sets to lists
|
||||
for constraint in constraints.values():
|
||||
constraint["columns"] = list(constraint["columns"])
|
||||
return constraints
|
||||
@@ -0,0 +1,104 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="override,attr-defined"
|
||||
|
||||
"""Database Operations."""
|
||||
|
||||
from datetime import datetime, time, timezone
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.backends.mysql.operations import (
|
||||
DatabaseOperations as MySQLDatabaseOperations,
|
||||
)
|
||||
from django.utils import timezone as django_timezone
|
||||
|
||||
try:
|
||||
from _mysql_connector import datetime_to_mysql, time_to_mysql
|
||||
except ImportError:
|
||||
HAVE_CEXT = False
|
||||
else:
|
||||
HAVE_CEXT = True
|
||||
|
||||
|
||||
class DatabaseOperations(MySQLDatabaseOperations):
|
||||
"""Database Operations class."""
|
||||
|
||||
compiler_module = "mysql.connector.django.compiler"
|
||||
|
||||
def regex_lookup(self, lookup_type: str) -> str:
|
||||
"""Return the string to use in a query when performing regular
|
||||
expression lookup."""
|
||||
if self.connection.mysql_version < (8, 0, 0):
|
||||
if lookup_type == "regex":
|
||||
return "%s REGEXP BINARY %s"
|
||||
return "%s REGEXP %s"
|
||||
|
||||
match_option = "c" if lookup_type == "regex" else "i"
|
||||
return f"REGEXP_LIKE(%s, %s, '{match_option}')"
|
||||
|
||||
def adapt_datetimefield_value(self, value: Optional[datetime]) -> Optional[bytes]:
|
||||
"""Transform a datetime value to an object compatible with what is
|
||||
expected by the backend driver for datetime columns."""
|
||||
return self.value_to_db_datetime(value)
|
||||
|
||||
def value_to_db_datetime(self, value: Optional[datetime]) -> Optional[bytes]:
|
||||
"""Convert value to MySQL DATETIME."""
|
||||
ans: Optional[bytes] = None
|
||||
if value is None:
|
||||
return ans
|
||||
# MySQL doesn't support tz-aware times
|
||||
if django_timezone.is_aware(value):
|
||||
if settings.USE_TZ:
|
||||
value = value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
else:
|
||||
raise ValueError("MySQL backend does not support timezone-aware times")
|
||||
if not self.connection.features.supports_microsecond_precision:
|
||||
value = value.replace(microsecond=0)
|
||||
if not self.connection.use_pure:
|
||||
return datetime_to_mysql(value)
|
||||
return self.connection.converter.to_mysql(value)
|
||||
|
||||
def adapt_timefield_value(self, value: Optional[time]) -> Optional[bytes]:
|
||||
"""Transform a time value to an object compatible with what is expected
|
||||
by the backend driver for time columns."""
|
||||
return self.value_to_db_time(value)
|
||||
|
||||
def value_to_db_time(self, value: Optional[time]) -> Optional[bytes]:
|
||||
"""Convert value to MySQL TIME."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# MySQL doesn't support tz-aware times
|
||||
if django_timezone.is_aware(value):
|
||||
raise ValueError("MySQL backend does not support timezone-aware times")
|
||||
|
||||
if not self.connection.use_pure:
|
||||
return time_to_mysql(value)
|
||||
return self.connection.converter.to_mysql(value)
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="override"
|
||||
|
||||
"""Database schema editor."""
|
||||
from typing import Any
|
||||
|
||||
from django.db.backends.mysql.schema import (
|
||||
DatabaseSchemaEditor as MySQLDatabaseSchemaEditor,
|
||||
)
|
||||
|
||||
|
||||
class DatabaseSchemaEditor(MySQLDatabaseSchemaEditor):
|
||||
"""This class is responsible for emitting schema-changing statements to the
|
||||
databases.
|
||||
"""
|
||||
|
||||
def quote_value(self, value: Any) -> Any:
|
||||
"""Quote value."""
|
||||
self.connection.ensure_connection()
|
||||
if isinstance(value, str):
|
||||
value = value.replace("%", "%%")
|
||||
quoted = self.connection.connection.converter.escape(value)
|
||||
if isinstance(value, str) and isinstance(quoted, bytes):
|
||||
quoted = quoted.decode()
|
||||
return quoted
|
||||
|
||||
def prepare_default(self, value: Any) -> Any:
|
||||
"""Implement the required abstract method.
|
||||
|
||||
MySQL has requires_literal_defaults=False, therefore return the value.
|
||||
"""
|
||||
return value
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Backend specific database validation."""
|
||||
|
||||
from django.db.backends.mysql.validation import DatabaseValidation
|
||||
|
||||
__all__ = ["DatabaseValidation"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,393 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Python exceptions."""
|
||||
from typing import Dict, Mapping, Optional, Tuple, Type, Union
|
||||
|
||||
from .locales import get_client_error
|
||||
from .types import StrOrBytes
|
||||
from .utils import read_bytes, read_int
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""Exception that is base class for all other error exceptions.
|
||||
|
||||
See [1] for more details.
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-api-errors-error.html
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
msg: Optional[str] = None,
|
||||
errno: Optional[int] = None,
|
||||
values: Optional[Tuple[Union[int, str], ...]] = None,
|
||||
sqlstate: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.msg = msg
|
||||
self._full_msg = self.msg
|
||||
self.errno = errno or -1
|
||||
self.sqlstate = sqlstate
|
||||
|
||||
if not self.msg and (2000 <= self.errno < 3000):
|
||||
self.msg = get_client_error(self.errno)
|
||||
if values is not None:
|
||||
try:
|
||||
self.msg = self.msg % values
|
||||
except TypeError as err:
|
||||
self.msg = f"{self.msg} (Warning: {err})"
|
||||
elif not self.msg:
|
||||
self._full_msg = self.msg = "Unknown error"
|
||||
|
||||
if self.msg and self.errno != -1:
|
||||
fields = {"errno": self.errno, "msg": self.msg}
|
||||
if self.sqlstate:
|
||||
fmt = "{errno} ({state}): {msg}"
|
||||
fields["state"] = self.sqlstate
|
||||
else:
|
||||
fmt = "{errno}: {msg}"
|
||||
self._full_msg = fmt.format(**fields)
|
||||
|
||||
self.args = (self.errno, self._full_msg, self.sqlstate)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self._full_msg
|
||||
|
||||
|
||||
class Warning(Exception): # pylint: disable=redefined-builtin
|
||||
"""Exception for important warnings"""
|
||||
|
||||
|
||||
class InterfaceError(Error):
|
||||
"""Exception for errors related to the interface"""
|
||||
|
||||
|
||||
class DatabaseError(Error):
|
||||
"""Exception for errors related to the database"""
|
||||
|
||||
|
||||
class InternalError(DatabaseError):
|
||||
"""Exception for errors internal database errors"""
|
||||
|
||||
|
||||
class OperationalError(DatabaseError):
|
||||
"""Exception for errors related to the database's operation"""
|
||||
|
||||
|
||||
class ProgrammingError(DatabaseError):
|
||||
"""Exception for errors programming errors"""
|
||||
|
||||
|
||||
class IntegrityError(DatabaseError):
|
||||
"""Exception for errors regarding relational integrity"""
|
||||
|
||||
|
||||
class DataError(DatabaseError):
|
||||
"""Exception for errors reporting problems with processed data"""
|
||||
|
||||
|
||||
class NotSupportedError(DatabaseError):
|
||||
"""Exception for errors when an unsupported database feature was used"""
|
||||
|
||||
|
||||
class PoolError(Error):
|
||||
"""Exception for errors relating to connection pooling"""
|
||||
|
||||
|
||||
class ConnectionTimeoutError(Error):
|
||||
"""
|
||||
Exception for errors related to the socket connection timing out while connecting with
|
||||
the server
|
||||
"""
|
||||
|
||||
|
||||
class ReadTimeoutError(Error):
|
||||
"""Exception for errors relating to socket timing out while receiving data from the server"""
|
||||
|
||||
DEFAULT_READ_TIMEOUT_ERROR_MSG: str = """
|
||||
The Read Operation timed out. As a consequence the current connection has been closed to avoid
|
||||
any unstable behaviour, consider using the reconnect() option and continue with the current
|
||||
session's workflow.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
msg: Optional[str] = None,
|
||||
errno: Optional[int] = None,
|
||||
values: Optional[Tuple[str, int]] = None,
|
||||
sqlstate: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(self.DEFAULT_READ_TIMEOUT_ERROR_MSG, errno, values, sqlstate)
|
||||
|
||||
|
||||
class WriteTimeoutError(Error):
|
||||
"""Exception for errors relating to socket timing out while sending data to the server"""
|
||||
|
||||
DEFAULT_WRITE_TIMEOUT_ERROR_MSG: str = """
|
||||
The Write Operation timed out. As a consequence the current connection has been closed to avoid
|
||||
any unstable behaviour, consider using the reconnect() option to continue with the current
|
||||
session's workflow.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
msg: Optional[str] = None,
|
||||
errno: Optional[int] = None,
|
||||
values: Optional[Tuple[str, int]] = None,
|
||||
sqlstate: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(self.DEFAULT_WRITE_TIMEOUT_ERROR_MSG, errno, values, sqlstate)
|
||||
|
||||
|
||||
ErrorClassTypes = Union[
|
||||
Type[Error],
|
||||
Type[InterfaceError],
|
||||
Type[DatabaseError],
|
||||
Type[InternalError],
|
||||
Type[OperationalError],
|
||||
Type[ProgrammingError],
|
||||
Type[IntegrityError],
|
||||
Type[DataError],
|
||||
Type[NotSupportedError],
|
||||
Type[PoolError],
|
||||
Type[ConnectionTimeoutError],
|
||||
Type[ReadTimeoutError],
|
||||
Type[WriteTimeoutError],
|
||||
]
|
||||
ErrorTypes = Union[
|
||||
Error,
|
||||
InterfaceError,
|
||||
DatabaseError,
|
||||
InternalError,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
IntegrityError,
|
||||
DataError,
|
||||
NotSupportedError,
|
||||
PoolError,
|
||||
Warning,
|
||||
ConnectionTimeoutError,
|
||||
ReadTimeoutError,
|
||||
WriteTimeoutError,
|
||||
]
|
||||
# _CUSTOM_ERROR_EXCEPTIONS holds custom exceptions and is used by the
|
||||
# function custom_error_exception. _ERROR_EXCEPTIONS (at bottom of module)
|
||||
# is similar, but hardcoded exceptions.
|
||||
_CUSTOM_ERROR_EXCEPTIONS: Dict[int, ErrorClassTypes] = {}
|
||||
|
||||
|
||||
def custom_error_exception(
|
||||
error: Optional[Union[int, Dict[int, Optional[ErrorClassTypes]]]] = None,
|
||||
exception: Optional[ErrorClassTypes] = None,
|
||||
) -> Mapping[int, Optional[ErrorClassTypes]]:
|
||||
"""Defines custom exceptions for MySQL server errors.
|
||||
|
||||
This function defines custom exceptions for MySQL server errors and
|
||||
returns the current set customizations.
|
||||
|
||||
To reset the customizations, simply supply an empty dictionary.
|
||||
|
||||
Args:
|
||||
error: Can be a MySQL Server error number or a dictionary in which case the
|
||||
key is the server error number and the value is the exception to be raised.
|
||||
exception: If `error` is a MySQL Server error number then you have to pass
|
||||
also the exception class, otherwise you don't.
|
||||
|
||||
Returns:
|
||||
dictionary: Current set customizations.
|
||||
|
||||
Examples:
|
||||
```
|
||||
import mysql.connector
|
||||
from mysql.connector import errorcode
|
||||
|
||||
# Server error 1028 should raise a DatabaseError
|
||||
mysql.connector.custom_error_exception(
|
||||
1028, mysql.connector.DatabaseError)
|
||||
|
||||
# Or using a dictionary:
|
||||
mysql.connector.custom_error_exception({
|
||||
1028: mysql.connector.DatabaseError,
|
||||
1029: mysql.connector.OperationalError,
|
||||
})
|
||||
|
||||
# Reset
|
||||
mysql.connector.custom_error_exception({})
|
||||
```
|
||||
"""
|
||||
global _CUSTOM_ERROR_EXCEPTIONS # pylint: disable=global-statement
|
||||
|
||||
if isinstance(error, dict) and not error:
|
||||
_CUSTOM_ERROR_EXCEPTIONS = {}
|
||||
return _CUSTOM_ERROR_EXCEPTIONS
|
||||
|
||||
if not error and not exception:
|
||||
return _CUSTOM_ERROR_EXCEPTIONS
|
||||
|
||||
if not isinstance(error, (int, dict)):
|
||||
raise ValueError("The error argument should be either an integer or dictionary")
|
||||
|
||||
if isinstance(error, int):
|
||||
error = {error: exception}
|
||||
|
||||
for errno, _exception in error.items():
|
||||
if not isinstance(errno, int):
|
||||
raise ValueError("Error number should be an integer")
|
||||
try:
|
||||
if _exception is None or not issubclass(_exception, Exception):
|
||||
raise TypeError
|
||||
except TypeError as err:
|
||||
raise ValueError("Exception should be subclass of Exception") from err
|
||||
_CUSTOM_ERROR_EXCEPTIONS[errno] = _exception
|
||||
|
||||
return _CUSTOM_ERROR_EXCEPTIONS
|
||||
|
||||
|
||||
def get_mysql_exception(
|
||||
errno: int,
|
||||
msg: Optional[str] = None,
|
||||
sqlstate: Optional[str] = None,
|
||||
warning: Optional[bool] = False,
|
||||
) -> ErrorTypes:
|
||||
"""Get the exception matching the MySQL error
|
||||
|
||||
This function will return an exception based on the SQLState. The given
|
||||
message will be passed on in the returned exception.
|
||||
|
||||
The exception returned can be customized using the
|
||||
mysql.connector.custom_error_exception() function.
|
||||
|
||||
Returns an Exception
|
||||
"""
|
||||
try:
|
||||
return _CUSTOM_ERROR_EXCEPTIONS[errno](msg=msg, errno=errno, sqlstate=sqlstate)
|
||||
except KeyError:
|
||||
# Error was not mapped to particular exception
|
||||
pass
|
||||
|
||||
try:
|
||||
return _ERROR_EXCEPTIONS[errno](msg=msg, errno=errno, sqlstate=sqlstate)
|
||||
except KeyError:
|
||||
# Error was not mapped to particular exception
|
||||
pass
|
||||
|
||||
if not sqlstate:
|
||||
if warning:
|
||||
return Warning(errno, msg)
|
||||
return DatabaseError(msg=msg, errno=errno)
|
||||
|
||||
try:
|
||||
return _SQLSTATE_CLASS_EXCEPTION[sqlstate[0:2]](
|
||||
msg=msg, errno=errno, sqlstate=sqlstate
|
||||
)
|
||||
except KeyError:
|
||||
# Return default InterfaceError
|
||||
return DatabaseError(msg=msg, errno=errno, sqlstate=sqlstate)
|
||||
|
||||
|
||||
def get_exception(packet: bytes) -> ErrorTypes:
|
||||
"""Returns an exception object based on the MySQL error
|
||||
|
||||
Returns an exception object based on the MySQL error in the given
|
||||
packet.
|
||||
|
||||
Returns an Error-Object.
|
||||
"""
|
||||
errno = errmsg = None
|
||||
|
||||
try:
|
||||
if packet[4] != 255:
|
||||
raise ValueError("Packet is not an error packet")
|
||||
except IndexError as err:
|
||||
return InterfaceError(f"Failed getting Error information ({err})")
|
||||
|
||||
sqlstate: Optional[StrOrBytes] = None
|
||||
try:
|
||||
packet = packet[5:]
|
||||
packet, errno = read_int(packet, 2)
|
||||
if packet[0] != 35:
|
||||
# Error without SQLState
|
||||
if isinstance(packet, (bytes, bytearray)):
|
||||
errmsg = packet.decode("utf8")
|
||||
else:
|
||||
errmsg = packet
|
||||
else:
|
||||
packet, sqlstate = read_bytes(packet[1:], 5)
|
||||
sqlstate = sqlstate.decode("utf8")
|
||||
errmsg = packet.decode("utf8")
|
||||
except (IndexError, UnicodeError) as err:
|
||||
return InterfaceError(f"Failed getting Error information ({err})")
|
||||
return get_mysql_exception(errno, errmsg, sqlstate) # type: ignore[arg-type]
|
||||
|
||||
|
||||
_SQLSTATE_CLASS_EXCEPTION: Dict[str, ErrorClassTypes] = {
|
||||
"02": DataError, # no data
|
||||
"07": DatabaseError, # dynamic SQL error
|
||||
"08": OperationalError, # connection exception
|
||||
"0A": NotSupportedError, # feature not supported
|
||||
"21": DataError, # cardinality violation
|
||||
"22": DataError, # data exception
|
||||
"23": IntegrityError, # integrity constraint violation
|
||||
"24": ProgrammingError, # invalid cursor state
|
||||
"25": ProgrammingError, # invalid transaction state
|
||||
"26": ProgrammingError, # invalid SQL statement name
|
||||
"27": ProgrammingError, # triggered data change violation
|
||||
"28": ProgrammingError, # invalid authorization specification
|
||||
"2A": ProgrammingError, # direct SQL syntax error or access rule violation
|
||||
"2B": DatabaseError, # dependent privilege descriptors still exist
|
||||
"2C": ProgrammingError, # invalid character set name
|
||||
"2D": DatabaseError, # invalid transaction termination
|
||||
"2E": DatabaseError, # invalid connection name
|
||||
"33": DatabaseError, # invalid SQL descriptor name
|
||||
"34": ProgrammingError, # invalid cursor name
|
||||
"35": ProgrammingError, # invalid condition number
|
||||
"37": ProgrammingError, # dynamic SQL syntax error or access rule violation
|
||||
"3C": ProgrammingError, # ambiguous cursor name
|
||||
"3D": ProgrammingError, # invalid catalog name
|
||||
"3F": ProgrammingError, # invalid schema name
|
||||
"40": InternalError, # transaction rollback
|
||||
"42": ProgrammingError, # syntax error or access rule violation
|
||||
"44": InternalError, # with check option violation
|
||||
"HZ": OperationalError, # remote database access
|
||||
"XA": IntegrityError,
|
||||
"0K": OperationalError,
|
||||
"HY": DatabaseError, # default when no SQLState provided by MySQL server
|
||||
}
|
||||
|
||||
_ERROR_EXCEPTIONS: Dict[int, ErrorClassTypes] = {
|
||||
1243: ProgrammingError,
|
||||
1210: ProgrammingError,
|
||||
2002: InterfaceError,
|
||||
2013: OperationalError,
|
||||
2049: NotSupportedError,
|
||||
2055: OperationalError,
|
||||
2061: InterfaceError,
|
||||
2026: InterfaceError,
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Copyright (c) 2012, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Translations."""
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
__all__: List[str] = ["get_client_error"]
|
||||
|
||||
from .. import errorcode
|
||||
|
||||
|
||||
def get_client_error(error: Union[int, str], language: str = "eng") -> Optional[str]:
|
||||
"""Lookup client error
|
||||
|
||||
This function will lookup the client error message based on the given
|
||||
error and return the error message. If the error was not found,
|
||||
None will be returned.
|
||||
|
||||
Error can be either an integer or a string. For example:
|
||||
error: 2000
|
||||
error: CR_UNKNOWN_ERROR
|
||||
|
||||
The language attribute can be used to retrieve a localized message, when
|
||||
available.
|
||||
|
||||
Returns a string or None.
|
||||
"""
|
||||
try:
|
||||
tmp = __import__(
|
||||
f"mysql.connector.locales.{language}",
|
||||
globals(),
|
||||
locals(),
|
||||
["client_error"],
|
||||
)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
f"No localization support for language '{language}'"
|
||||
) from None
|
||||
client_error = tmp.client_error
|
||||
|
||||
if isinstance(error, int):
|
||||
errno = error
|
||||
for key, value in errorcode.__dict__.items():
|
||||
if value == errno:
|
||||
error = key
|
||||
break
|
||||
|
||||
if isinstance(error, (str)):
|
||||
try:
|
||||
return getattr(client_error, error)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
raise ValueError("error argument needs to be either an integer or string")
|
||||
@@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2012, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""English Content
|
||||
"""
|
||||
@@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (c) 2013, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""MySQL Error Messages."""
|
||||
|
||||
# This file was auto-generated.
|
||||
_GENERATED_ON = "2021-08-11"
|
||||
_MYSQL_VERSION = (8, 0, 27)
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
# Start MySQL Error messages
|
||||
CR_UNKNOWN_ERROR = "Unknown MySQL error"
|
||||
CR_SOCKET_CREATE_ERROR = "Can't create UNIX socket (%s)"
|
||||
CR_CONNECTION_ERROR = (
|
||||
"Can't connect to local MySQL server through socket '%-.100s' (%s)"
|
||||
)
|
||||
CR_CONN_HOST_ERROR = "Can't connect to MySQL server on '%-.100s:%u' (%s)"
|
||||
CR_IPSOCK_ERROR = "Can't create TCP/IP socket (%s)"
|
||||
CR_UNKNOWN_HOST = "Unknown MySQL server host '%-.100s' (%s)"
|
||||
CR_SERVER_GONE_ERROR = "MySQL server has gone away"
|
||||
CR_VERSION_ERROR = "Protocol mismatch; server version = %s, client version = %s"
|
||||
CR_OUT_OF_MEMORY = "MySQL client ran out of memory"
|
||||
CR_WRONG_HOST_INFO = "Wrong host info"
|
||||
CR_LOCALHOST_CONNECTION = "Localhost via UNIX socket"
|
||||
CR_TCP_CONNECTION = "%-.100s via TCP/IP"
|
||||
CR_SERVER_HANDSHAKE_ERR = "Error in server handshake"
|
||||
CR_SERVER_LOST = "Lost connection to MySQL server during query"
|
||||
CR_COMMANDS_OUT_OF_SYNC = "Commands out of sync; you can't run this command now"
|
||||
CR_NAMEDPIPE_CONNECTION = "Named pipe: %-.32s"
|
||||
CR_NAMEDPIPEWAIT_ERROR = "Can't wait for named pipe to host: %-.64s pipe: %-.32s (%s)"
|
||||
CR_NAMEDPIPEOPEN_ERROR = "Can't open named pipe to host: %-.64s pipe: %-.32s (%s)"
|
||||
CR_NAMEDPIPESETSTATE_ERROR = (
|
||||
"Can't set state of named pipe to host: %-.64s pipe: %-.32s (%s)"
|
||||
)
|
||||
CR_CANT_READ_CHARSET = "Can't initialize character set %-.32s (path: %-.100s)"
|
||||
CR_NET_PACKET_TOO_LARGE = "Got packet bigger than 'max_allowed_packet' bytes"
|
||||
CR_EMBEDDED_CONNECTION = "Embedded server"
|
||||
CR_PROBE_SLAVE_STATUS = "Error on SHOW SLAVE STATUS:"
|
||||
CR_PROBE_SLAVE_HOSTS = "Error on SHOW SLAVE HOSTS:"
|
||||
CR_PROBE_SLAVE_CONNECT = "Error connecting to slave:"
|
||||
CR_PROBE_MASTER_CONNECT = "Error connecting to master:"
|
||||
CR_SSL_CONNECTION_ERROR = "SSL connection error: %-.100s"
|
||||
CR_MALFORMED_PACKET = "Malformed packet"
|
||||
CR_WRONG_LICENSE = "This client library is licensed only for use with MySQL servers having '%s' license"
|
||||
CR_NULL_POINTER = "Invalid use of null pointer"
|
||||
CR_NO_PREPARE_STMT = "Statement not prepared"
|
||||
CR_PARAMS_NOT_BOUND = "No data supplied for parameters in prepared statement"
|
||||
CR_DATA_TRUNCATED = "Data truncated"
|
||||
CR_NO_PARAMETERS_EXISTS = "No parameters exist in the statement"
|
||||
CR_INVALID_PARAMETER_NO = "Invalid parameter number"
|
||||
CR_INVALID_BUFFER_USE = (
|
||||
"Can't send long data for non-string/non-binary data types (parameter: %s)"
|
||||
)
|
||||
CR_UNSUPPORTED_PARAM_TYPE = "Using unsupported buffer type: %s (parameter: %s)"
|
||||
CR_SHARED_MEMORY_CONNECTION = "Shared memory: %-.100s"
|
||||
CR_SHARED_MEMORY_CONNECT_REQUEST_ERROR = (
|
||||
"Can't open shared memory; client could not create request event (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_CONNECT_ANSWER_ERROR = (
|
||||
"Can't open shared memory; no answer event received from server (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_CONNECT_FILE_MAP_ERROR = (
|
||||
"Can't open shared memory; server could not allocate file mapping (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_CONNECT_MAP_ERROR = (
|
||||
"Can't open shared memory; server could not get pointer to file mapping (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_FILE_MAP_ERROR = (
|
||||
"Can't open shared memory; client could not allocate file mapping (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_MAP_ERROR = (
|
||||
"Can't open shared memory; client could not get pointer to file mapping (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_EVENT_ERROR = (
|
||||
"Can't open shared memory; client could not create %s event (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_CONNECT_ABANDONED_ERROR = (
|
||||
"Can't open shared memory; no answer from server (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_CONNECT_SET_ERROR = (
|
||||
"Can't open shared memory; cannot send request event to server (%s)"
|
||||
)
|
||||
CR_CONN_UNKNOW_PROTOCOL = "Wrong or unknown protocol"
|
||||
CR_INVALID_CONN_HANDLE = "Invalid connection handle"
|
||||
CR_UNUSED_1 = "Connection using old (pre-4.1.1) authentication protocol refused (client option 'secure_auth' enabled)"
|
||||
CR_FETCH_CANCELED = "Row retrieval was canceled by mysql_stmt_close() call"
|
||||
CR_NO_DATA = "Attempt to read column without prior row fetch"
|
||||
CR_NO_STMT_METADATA = "Prepared statement contains no metadata"
|
||||
CR_NO_RESULT_SET = (
|
||||
"Attempt to read a row while there is no result set associated with the statement"
|
||||
)
|
||||
CR_NOT_IMPLEMENTED = "This feature is not implemented yet"
|
||||
CR_SERVER_LOST_EXTENDED = "Lost connection to MySQL server at '%s', system error: %s"
|
||||
CR_STMT_CLOSED = "Statement closed indirectly because of a preceding %s() call"
|
||||
CR_NEW_STMT_METADATA = "The number of columns in the result set differs from the number of bound buffers. You must reset the statement, rebind the result set columns, and execute the statement again"
|
||||
CR_ALREADY_CONNECTED = (
|
||||
"This handle is already connected. Use a separate handle for each connection."
|
||||
)
|
||||
CR_AUTH_PLUGIN_CANNOT_LOAD = "Authentication plugin '%s' cannot be loaded: %s"
|
||||
CR_DUPLICATE_CONNECTION_ATTR = "There is an attribute with the same name already"
|
||||
CR_AUTH_PLUGIN_ERR = "Authentication plugin '%s' reported error: %s"
|
||||
CR_INSECURE_API_ERR = "Insecure API function call: '%s' Use instead: '%s'"
|
||||
CR_FILE_NAME_TOO_LONG = "File name is too long"
|
||||
CR_SSL_FIPS_MODE_ERR = "Set FIPS mode ON/STRICT failed"
|
||||
CR_DEPRECATED_COMPRESSION_NOT_SUPPORTED = (
|
||||
"Compression protocol not supported with asynchronous protocol"
|
||||
)
|
||||
CR_COMPRESSION_WRONGLY_CONFIGURED = (
|
||||
"Connection failed due to wrongly configured compression algorithm"
|
||||
)
|
||||
CR_KERBEROS_USER_NOT_FOUND = (
|
||||
"SSO user not found, Please perform SSO authentication using kerberos."
|
||||
)
|
||||
CR_LOAD_DATA_LOCAL_INFILE_REJECTED = (
|
||||
"LOAD DATA LOCAL INFILE file request rejected due to restrictions on access."
|
||||
)
|
||||
CR_LOAD_DATA_LOCAL_INFILE_REALPATH_FAIL = (
|
||||
"Determining the real path for '%s' failed with error (%s): %s"
|
||||
)
|
||||
CR_DNS_SRV_LOOKUP_FAILED = "DNS SRV lookup failed with error : %s"
|
||||
CR_MANDATORY_TRACKER_NOT_FOUND = (
|
||||
"Client does not recognise tracker type %s marked as mandatory by server."
|
||||
)
|
||||
CR_INVALID_FACTOR_NO = "Invalid first argument for MYSQL_OPT_USER_PASSWORD option. Valid value should be between 1 and 3 inclusive."
|
||||
# End MySQL Error messages
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2022, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Setup of the `mysql.connector` logger."""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("mysql.connector")
|
||||
@@ -0,0 +1,811 @@
|
||||
# Copyright (c) 2012, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Module implementing low-level socket communication with MySQL servers."""
|
||||
|
||||
# pylint: disable=overlapping-except
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import warnings
|
||||
import zlib
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import deque
|
||||
from typing import Any, Deque, List, Optional, Tuple, Union
|
||||
|
||||
try:
|
||||
import ssl
|
||||
|
||||
TLS_VERSIONS = {
|
||||
"TLSv1": ssl.PROTOCOL_TLSv1,
|
||||
"TLSv1.1": ssl.PROTOCOL_TLSv1_1,
|
||||
"TLSv1.2": ssl.PROTOCOL_TLSv1_2,
|
||||
"TLSv1.3": ssl.PROTOCOL_TLS,
|
||||
}
|
||||
TLS_V1_3_SUPPORTED = hasattr(ssl, "HAS_TLSv1_3") and ssl.HAS_TLSv1_3
|
||||
except ImportError:
|
||||
# If import fails, we don't have SSL support.
|
||||
TLS_V1_3_SUPPORTED = False
|
||||
ssl = None
|
||||
|
||||
from .errors import (
|
||||
ConnectionTimeoutError,
|
||||
InterfaceError,
|
||||
NotSupportedError,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
ReadTimeoutError,
|
||||
WriteTimeoutError,
|
||||
)
|
||||
|
||||
MIN_COMPRESS_LENGTH = 50
|
||||
MAX_PAYLOAD_LENGTH = 2**24 - 1
|
||||
PACKET_HEADER_LENGTH = 4
|
||||
COMPRESSED_PACKET_HEADER_LENGTH = 7
|
||||
|
||||
|
||||
def _strioerror(err: IOError) -> str:
|
||||
"""Reformat the IOError error message.
|
||||
|
||||
This function reformats the IOError error message.
|
||||
"""
|
||||
return str(err) if not err.errno else f"Errno {err.errno}: {err.strerror}"
|
||||
|
||||
|
||||
class NetworkBroker(ABC):
|
||||
"""Broker class interface.
|
||||
|
||||
The network object is a broker used as a delegate by a socket object. Whenever the
|
||||
socket wants to deliver or get packets to or from the MySQL server it needs to rely
|
||||
on its network broker (netbroker).
|
||||
|
||||
The netbroker sends `payloads` and receives `packets`.
|
||||
|
||||
A packet is a bytes sequence, it has a header and body (referred to as payload).
|
||||
The first `PACKET_HEADER_LENGTH` or `COMPRESSED_PACKET_HEADER_LENGTH`
|
||||
(as appropriate) bytes correspond to the `header`, the remaining ones represent the
|
||||
`payload`.
|
||||
|
||||
The maximum payload length allowed to be sent per packet to the server is
|
||||
`MAX_PAYLOAD_LENGTH`. When `send` is called with a payload whose length is greater
|
||||
than `MAX_PAYLOAD_LENGTH` the netbroker breaks it down into packets, so the caller
|
||||
of `send` can provide payloads of arbitrary length.
|
||||
|
||||
Finally, data received by the netbroker comes directly from the server, expect to
|
||||
get a packet for each call to `recv`. The received packet contains a header and
|
||||
payload, the latter respecting `MAX_PAYLOAD_LENGTH`.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def send(
|
||||
self,
|
||||
sock: socket.socket,
|
||||
address: str,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send `payload` to the MySQL server.
|
||||
|
||||
If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is
|
||||
broken down into packets.
|
||||
|
||||
Args:
|
||||
sock: Object holding the socket connection.
|
||||
address: Socket's location.
|
||||
payload: Packet's body to send.
|
||||
packet_number: Sequence id (packet ID) to attach to the header when sending
|
||||
plain packets.
|
||||
compressed_packet_number: Same as `packet_number` but used when sending
|
||||
compressed packets.
|
||||
|
||||
Raises:
|
||||
:class:`OperationalError`: If something goes wrong while sending packets to
|
||||
the MySQL server.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def recv(self, sock: socket.socket, address: str) -> bytearray:
|
||||
"""Get the next available packet from the MySQL server.
|
||||
|
||||
Args:
|
||||
sock: Object holding the socket connection.
|
||||
address: Socket's location.
|
||||
|
||||
Returns:
|
||||
packet: A packet from the MySQL server.
|
||||
|
||||
Raises:
|
||||
:class:`OperationalError`: If something goes wrong while receiving packets
|
||||
from the MySQL server.
|
||||
:class:`InterfaceError`: If something goes wrong while receiving packets
|
||||
from the MySQL server.
|
||||
"""
|
||||
|
||||
|
||||
class NetworkBrokerPlain(NetworkBroker):
|
||||
"""Broker class for MySQL socket communication."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pktnr: int = -1 # packet number
|
||||
|
||||
def _set_next_pktnr(self) -> None:
|
||||
"""Increment packet id."""
|
||||
self._pktnr = (self._pktnr + 1) % 256
|
||||
|
||||
def _send_pkt(self, sock: socket.socket, address: str, pkt: bytes) -> None:
|
||||
"""Write packet to the comm channel."""
|
||||
try:
|
||||
sock.sendall(pkt)
|
||||
except (socket.timeout, TimeoutError) as err:
|
||||
raise WriteTimeoutError(errno=3024) from err
|
||||
except IOError as err:
|
||||
raise OperationalError(
|
||||
errno=2055, values=(address, _strioerror(err))
|
||||
) from err
|
||||
except AttributeError as err:
|
||||
raise OperationalError(errno=2006) from err
|
||||
|
||||
def _recv_chunk(self, sock: socket.socket, size: int = 0) -> bytearray:
|
||||
"""Read `size` bytes from the comm channel."""
|
||||
pkt = bytearray(size)
|
||||
pkt_view = memoryview(pkt)
|
||||
while size:
|
||||
read = sock.recv_into(pkt_view, size)
|
||||
if read == 0 and size > 0:
|
||||
raise InterfaceError(errno=2013)
|
||||
pkt_view = pkt_view[read:]
|
||||
size -= read
|
||||
return pkt
|
||||
|
||||
def send(
|
||||
self,
|
||||
sock: socket.socket,
|
||||
address: str,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send payload to the MySQL server.
|
||||
|
||||
If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is
|
||||
broken down into packets.
|
||||
"""
|
||||
if packet_number is None:
|
||||
self._set_next_pktnr()
|
||||
else:
|
||||
self._pktnr = packet_number
|
||||
|
||||
# If the payload is larger than or equal to MAX_PAYLOAD_LENGTH
|
||||
# the length is set to 2^24 - 1 (ff ff ff) and additional
|
||||
# packets are sent with the rest of the payload until the
|
||||
# payload of a packet is less than MAX_PAYLOAD_LENGTH.
|
||||
if len(payload) >= MAX_PAYLOAD_LENGTH:
|
||||
offset = 0
|
||||
for _ in range(len(payload) // MAX_PAYLOAD_LENGTH):
|
||||
# payload_len, sequence_id, payload
|
||||
self._send_pkt(
|
||||
sock,
|
||||
address,
|
||||
b"\xff\xff\xff"
|
||||
+ struct.pack("<B", self._pktnr)
|
||||
+ payload[offset : offset + MAX_PAYLOAD_LENGTH],
|
||||
)
|
||||
self._set_next_pktnr()
|
||||
offset += MAX_PAYLOAD_LENGTH
|
||||
payload = payload[offset:]
|
||||
self._send_pkt(
|
||||
sock,
|
||||
address,
|
||||
struct.pack("<I", len(payload))[0:3]
|
||||
+ struct.pack("<B", self._pktnr)
|
||||
+ payload,
|
||||
)
|
||||
|
||||
def recv(self, sock: socket.socket, address: str) -> bytearray:
|
||||
"""Receive `one` packet from the MySQL server."""
|
||||
try:
|
||||
# Read the header of the MySQL packet
|
||||
header = self._recv_chunk(sock, size=PACKET_HEADER_LENGTH)
|
||||
|
||||
# Pull the payload length and sequence id
|
||||
payload_len, self._pktnr = (
|
||||
struct.unpack("<I", header[0:3] + b"\x00")[0],
|
||||
header[3],
|
||||
)
|
||||
|
||||
# Read the payload, and return packet
|
||||
return header + self._recv_chunk(sock, size=payload_len)
|
||||
except (socket.timeout, TimeoutError) as err:
|
||||
raise ReadTimeoutError(errno=3024, msg=err.strerror) from err
|
||||
except IOError as err:
|
||||
raise OperationalError(
|
||||
errno=2055, values=(address, _strioerror(err))
|
||||
) from err
|
||||
|
||||
|
||||
class NetworkBrokerCompressed(NetworkBrokerPlain):
|
||||
"""Broker class for MySQL socket communication."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._compressed_pktnr = -1
|
||||
self._queue_read: Deque[bytearray] = deque()
|
||||
|
||||
@staticmethod
|
||||
def _prepare_packets(payload: bytes, pktnr: int) -> List[bytes]:
|
||||
"""Prepare a payload for sending to the MySQL server."""
|
||||
pkts = []
|
||||
|
||||
# If the payload is larger than or equal to MAX_PAYLOAD_LENGTH
|
||||
# the length is set to 2^24 - 1 (ff ff ff) and additional
|
||||
# packets are sent with the rest of the payload until the
|
||||
# payload of a packet is less than MAX_PAYLOAD_LENGTH.
|
||||
if len(payload) >= MAX_PAYLOAD_LENGTH:
|
||||
offset = 0
|
||||
for _ in range(len(payload) // MAX_PAYLOAD_LENGTH):
|
||||
# payload length + sequence id + payload
|
||||
pkts.append(
|
||||
b"\xff\xff\xff"
|
||||
+ struct.pack("<B", pktnr)
|
||||
+ payload[offset : offset + MAX_PAYLOAD_LENGTH]
|
||||
)
|
||||
pktnr = (pktnr + 1) % 256
|
||||
offset += MAX_PAYLOAD_LENGTH
|
||||
payload = payload[offset:]
|
||||
pkts.append(
|
||||
struct.pack("<I", len(payload))[0:3] + struct.pack("<B", pktnr) + payload
|
||||
)
|
||||
return pkts
|
||||
|
||||
def _set_next_compressed_pktnr(self) -> None:
|
||||
"""Increment packet id."""
|
||||
self._compressed_pktnr = (self._compressed_pktnr + 1) % 256
|
||||
|
||||
def _send_pkt(self, sock: socket.socket, address: str, pkt: bytes) -> None:
|
||||
"""Compress packet and write it to the comm channel."""
|
||||
compressed_pkt = zlib.compress(pkt)
|
||||
pkt = (
|
||||
struct.pack("<I", len(compressed_pkt))[0:3]
|
||||
+ struct.pack("<B", self._compressed_pktnr)
|
||||
+ struct.pack("<I", len(pkt))[0:3]
|
||||
+ compressed_pkt
|
||||
)
|
||||
return super()._send_pkt(sock, address, pkt)
|
||||
|
||||
def send(
|
||||
self,
|
||||
sock: socket.socket,
|
||||
address: str,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send `payload` as compressed packets to the MySQL server.
|
||||
|
||||
If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is
|
||||
broken down into packets.
|
||||
"""
|
||||
# get next packet numbers
|
||||
if packet_number is None:
|
||||
self._set_next_pktnr()
|
||||
else:
|
||||
self._pktnr = packet_number
|
||||
if compressed_packet_number is None:
|
||||
self._set_next_compressed_pktnr()
|
||||
else:
|
||||
self._compressed_pktnr = compressed_packet_number
|
||||
|
||||
payload_prep = bytearray(b"").join(self._prepare_packets(payload, self._pktnr))
|
||||
if len(payload) >= MAX_PAYLOAD_LENGTH - PACKET_HEADER_LENGTH:
|
||||
# sending a MySQL payload of the size greater or equal to 2^24 - 5
|
||||
# via compression leads to at least one extra compressed packet
|
||||
# WHY? let's say len(payload) is MAX_PAYLOAD_LENGTH - 3; when preparing
|
||||
# the payload, a header of size PACKET_HEADER_LENGTH is pre-appended
|
||||
# to the payload. This means that len(payload_prep) is
|
||||
# MAX_PAYLOAD_LENGTH - 3 + PACKET_HEADER_LENGTH = MAX_PAYLOAD_LENGTH + 1
|
||||
# surpassing the maximum allowed payload size per packet.
|
||||
offset = 0
|
||||
|
||||
# send several MySQL packets
|
||||
for _ in range(len(payload_prep) // MAX_PAYLOAD_LENGTH):
|
||||
self._send_pkt(
|
||||
sock, address, payload_prep[offset : offset + MAX_PAYLOAD_LENGTH]
|
||||
)
|
||||
self._set_next_compressed_pktnr()
|
||||
offset += MAX_PAYLOAD_LENGTH
|
||||
self._send_pkt(sock, address, payload_prep[offset:])
|
||||
else:
|
||||
# send one MySQL packet
|
||||
# For small packets it may be too costly to compress the packet.
|
||||
# Usually payloads less than 50 bytes (MIN_COMPRESS_LENGTH)
|
||||
# aren't compressed (see MySQL source code Documentation).
|
||||
if len(payload) > MIN_COMPRESS_LENGTH:
|
||||
# perform compression
|
||||
self._send_pkt(sock, address, payload_prep)
|
||||
else:
|
||||
# skip compression
|
||||
super()._send_pkt(
|
||||
sock,
|
||||
address,
|
||||
struct.pack("<I", len(payload_prep))[0:3]
|
||||
+ struct.pack("<B", self._compressed_pktnr)
|
||||
+ struct.pack("<I", 0)[0:3]
|
||||
+ payload_prep,
|
||||
)
|
||||
|
||||
def _recv_compressed_pkt(
|
||||
self, sock: socket.socket, compressed_pll: int, uncompressed_pll: int
|
||||
) -> None:
|
||||
"""Handle reading of a compressed packet."""
|
||||
# compressed_pll stands for compressed payload length.
|
||||
# Recalling that if uncompressed payload length == 0, the packet
|
||||
# comes in uncompressed, so no decompression is needed.
|
||||
compressed_pkt = super()._recv_chunk(sock, size=compressed_pll)
|
||||
pkt = (
|
||||
compressed_pkt
|
||||
if uncompressed_pll == 0
|
||||
else bytearray(zlib.decompress(compressed_pkt))
|
||||
)
|
||||
|
||||
offset = 0
|
||||
while offset < len(pkt):
|
||||
# pll stands for payload length
|
||||
pll = struct.unpack(
|
||||
"<I", pkt[offset : offset + PACKET_HEADER_LENGTH - 1] + b"\x00"
|
||||
)[0]
|
||||
if PACKET_HEADER_LENGTH + pll > len(pkt) - offset:
|
||||
# More bytes need to be consumed
|
||||
# Read the header of the next MySQL packet
|
||||
header = super()._recv_chunk(sock, size=COMPRESSED_PACKET_HEADER_LENGTH)
|
||||
|
||||
# compressed payload length, sequence id, uncompressed payload length
|
||||
(
|
||||
compressed_pll,
|
||||
self._compressed_pktnr,
|
||||
uncompressed_pll,
|
||||
) = (
|
||||
struct.unpack("<I", header[0:3] + b"\x00")[0],
|
||||
header[3],
|
||||
struct.unpack("<I", header[4:7] + b"\x00")[0],
|
||||
)
|
||||
compressed_pkt = super()._recv_chunk(sock, size=compressed_pll)
|
||||
|
||||
# recalling that if uncompressed payload length == 0, the packet
|
||||
# comes in uncompressed, so no decompression is needed.
|
||||
pkt += (
|
||||
compressed_pkt
|
||||
if uncompressed_pll == 0
|
||||
else zlib.decompress(compressed_pkt)
|
||||
)
|
||||
|
||||
self._queue_read.append(pkt[offset : offset + PACKET_HEADER_LENGTH + pll])
|
||||
offset += PACKET_HEADER_LENGTH + pll
|
||||
|
||||
def recv(self, sock: socket.socket, address: str) -> bytearray:
|
||||
"""Receive `one` or `several` packets from the MySQL server, enqueue them, and
|
||||
return the packet at the head.
|
||||
"""
|
||||
if not self._queue_read:
|
||||
try:
|
||||
# Read the header of the next MySQL packet
|
||||
header = super()._recv_chunk(sock, size=COMPRESSED_PACKET_HEADER_LENGTH)
|
||||
|
||||
# compressed payload length, sequence id, uncompressed payload length
|
||||
(
|
||||
compressed_pll,
|
||||
self._compressed_pktnr,
|
||||
uncompressed_pll,
|
||||
) = (
|
||||
struct.unpack("<I", header[0:3] + b"\x00")[0],
|
||||
header[3],
|
||||
struct.unpack("<I", header[4:7] + b"\x00")[0],
|
||||
)
|
||||
self._recv_compressed_pkt(sock, compressed_pll, uncompressed_pll)
|
||||
except (socket.timeout, TimeoutError) as err:
|
||||
raise ReadTimeoutError(errno=3024) from err
|
||||
except IOError as err:
|
||||
raise OperationalError(
|
||||
errno=2055, values=(address, _strioerror(err))
|
||||
) from err
|
||||
|
||||
if not self._queue_read:
|
||||
return None
|
||||
|
||||
pkt = self._queue_read.popleft()
|
||||
self._pktnr = pkt[3]
|
||||
|
||||
return pkt
|
||||
|
||||
|
||||
class MySQLSocket(ABC):
|
||||
"""MySQL socket communication interface.
|
||||
|
||||
Examples:
|
||||
Subclasses: network.MySQLTCPSocket and network.MySQLUnixSocket.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Network layer where transactions are made with plain (uncompressed) packets
|
||||
is enabled by default.
|
||||
"""
|
||||
# holds the socket connection
|
||||
self.sock: Optional[socket.socket] = None
|
||||
self._connection_timeout: Optional[int] = None
|
||||
self.server_host: Optional[str] = None
|
||||
self._netbroker: NetworkBroker = NetworkBrokerPlain()
|
||||
|
||||
def switch_to_compressed_mode(self) -> None:
|
||||
"""Enable network layer where transactions are made with compressed packets."""
|
||||
self._netbroker = NetworkBrokerCompressed()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shut down the socket before closing it."""
|
||||
try:
|
||||
self.sock.shutdown(socket.SHUT_RDWR)
|
||||
self.sock.close()
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
def close_connection(self) -> None:
|
||||
"""Close the socket."""
|
||||
try:
|
||||
self.sock.close()
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.shutdown()
|
||||
|
||||
def set_connection_timeout(self, timeout: Optional[int]) -> None:
|
||||
"""Set the connection timeout."""
|
||||
self._connection_timeout = timeout
|
||||
if self.sock:
|
||||
self.sock.settimeout(timeout)
|
||||
|
||||
def switch_to_ssl(self, ssl_context: Any, host: str) -> None:
|
||||
"""Upgrade an existing connection to TLS.
|
||||
|
||||
Args:
|
||||
ssl_context (ssl.SSLContext): The SSL Context to be used.
|
||||
host (str): Server host name.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
|
||||
Raises:
|
||||
ProgrammingError: If the transport does not expose the socket instance.
|
||||
NotSupportedError: If Python installation has no SSL support.
|
||||
"""
|
||||
# Ensure that self.sock is already created
|
||||
assert self.sock is not None
|
||||
|
||||
if self.sock.family == 1: # socket.AF_UNIX
|
||||
raise ProgrammingError("SSL is not supported when using Unix sockets")
|
||||
|
||||
if ssl is None:
|
||||
raise NotSupportedError("Python installation has no SSL support")
|
||||
|
||||
try:
|
||||
self.sock = ssl_context.wrap_socket(self.sock, server_hostname=host)
|
||||
except NameError as err:
|
||||
raise NotSupportedError("Python installation has no SSL support") from err
|
||||
except (ssl.SSLError, IOError) as err:
|
||||
raise InterfaceError(
|
||||
errno=2055, values=(self.address, _strioerror(err))
|
||||
) from err
|
||||
except ssl.CertificateError as err:
|
||||
raise InterfaceError(str(err)) from err
|
||||
except NotImplementedError as err:
|
||||
raise InterfaceError(str(err)) from err
|
||||
|
||||
def build_ssl_context(
|
||||
self,
|
||||
ssl_ca: Optional[str] = None,
|
||||
ssl_cert: Optional[str] = None,
|
||||
ssl_key: Optional[str] = None,
|
||||
ssl_verify_cert: Optional[bool] = False,
|
||||
ssl_verify_identity: Optional[bool] = False,
|
||||
tls_versions: Optional[List[str]] = None,
|
||||
tls_cipher_suites: Optional[List[str]] = None,
|
||||
) -> Any:
|
||||
"""Build a SSLContext.
|
||||
|
||||
Args:
|
||||
ssl_ca: Certificate authority, opptional.
|
||||
ssl_cert: SSL certificate, optional.
|
||||
ssl_key: Private key, optional.
|
||||
ssl_verify_cert: Verify the SSL certificate if `True`.
|
||||
ssl_verify_identity: Verify host identity if `True`.
|
||||
tls_versions: TLS protocol versions, optional.
|
||||
tls_cipher_suites: Set of steps that helps to establish a secure connection.
|
||||
|
||||
Returns:
|
||||
ssl_context (ssl.SSLContext): An SSL Context ready be used.
|
||||
|
||||
Raises:
|
||||
NotSupportedError: Python installation has no SSL support.
|
||||
InterfaceError: Socket undefined or invalid ssl data.
|
||||
"""
|
||||
tls_version: Optional[str] = None
|
||||
|
||||
if not self.sock:
|
||||
raise InterfaceError(errno=2048)
|
||||
|
||||
if ssl is None:
|
||||
raise NotSupportedError("Python installation has no SSL support")
|
||||
|
||||
if tls_versions is None:
|
||||
tls_versions = []
|
||||
|
||||
if tls_cipher_suites is None:
|
||||
tls_cipher_suites = []
|
||||
|
||||
try:
|
||||
if tls_versions:
|
||||
tls_versions.sort(reverse=True)
|
||||
tls_version = tls_versions[0]
|
||||
ssl_protocol = TLS_VERSIONS[tls_version]
|
||||
context = ssl.SSLContext(ssl_protocol)
|
||||
|
||||
if tls_version == "TLSv1.3":
|
||||
if "TLSv1.2" not in tls_versions:
|
||||
context.options |= ssl.OP_NO_TLSv1_2
|
||||
if "TLSv1.1" not in tls_versions:
|
||||
context.options |= ssl.OP_NO_TLSv1_1
|
||||
if "TLSv1" not in tls_versions:
|
||||
context.options |= ssl.OP_NO_TLSv1
|
||||
else:
|
||||
# `check_hostname` is True by default
|
||||
context = ssl.create_default_context()
|
||||
|
||||
context.check_hostname = ssl_verify_identity
|
||||
|
||||
if ssl_verify_cert:
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
elif ssl_verify_identity:
|
||||
context.verify_mode = ssl.CERT_OPTIONAL
|
||||
else:
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
context.load_default_certs()
|
||||
|
||||
if ssl_ca:
|
||||
try:
|
||||
context.load_verify_locations(ssl_ca)
|
||||
except (IOError, ssl.SSLError) as err:
|
||||
raise InterfaceError(f"Invalid CA Certificate: {err}") from err
|
||||
if ssl_cert:
|
||||
try:
|
||||
context.load_cert_chain(ssl_cert, ssl_key)
|
||||
except (IOError, ssl.SSLError) as err:
|
||||
raise InterfaceError(f"Invalid Certificate/Key: {err}") from err
|
||||
|
||||
# TLSv1.3 ciphers cannot be disabled with `SSLContext.set_ciphers(...)`,
|
||||
# see https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_ciphers.
|
||||
if tls_cipher_suites and tls_version == "TLSv1.2":
|
||||
context.set_ciphers(":".join(tls_cipher_suites))
|
||||
|
||||
return context
|
||||
except NameError as err:
|
||||
raise NotSupportedError("Python installation has no SSL support") from err
|
||||
except (
|
||||
IOError,
|
||||
NotImplementedError,
|
||||
ssl.CertificateError,
|
||||
ssl.SSLError,
|
||||
) as err:
|
||||
raise InterfaceError(str(err)) from err
|
||||
|
||||
def send(
|
||||
self,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send `payload` to the MySQL server.
|
||||
|
||||
NOTE: if `payload` is an instance of `bytearray`, then `payload` might be
|
||||
changed by this method - `bytearray` is similar to passing a variable by
|
||||
reference.
|
||||
|
||||
If you're sure you won't read `payload` after invoking `send()`,
|
||||
then you can use `bytearray.` Otherwise, you must use `bytes`.
|
||||
"""
|
||||
try:
|
||||
if (
|
||||
not self._connection_timeout and self.sock is not None
|
||||
): # can't update the timeout during connection phase
|
||||
self.sock.settimeout(float(write_timeout) if write_timeout else None)
|
||||
except OSError as _:
|
||||
# Ignore the OSError as the socket might not be setup properly
|
||||
pass
|
||||
self._netbroker.send(
|
||||
self.sock,
|
||||
self.address,
|
||||
payload,
|
||||
packet_number=packet_number,
|
||||
compressed_packet_number=compressed_packet_number,
|
||||
)
|
||||
|
||||
def recv(self, read_timeout: Optional[int] = None) -> bytearray:
|
||||
"""Get packet from the MySQL server comm channel."""
|
||||
try:
|
||||
if (
|
||||
not self._connection_timeout and self.sock is not None
|
||||
): # can't update the timeout during connection phase
|
||||
self.sock.settimeout(float(read_timeout) if read_timeout else None)
|
||||
except OSError as _:
|
||||
# Ignore the OSError as the socket might not be setup properly
|
||||
pass
|
||||
return self._netbroker.recv(self.sock, self.address)
|
||||
|
||||
@abstractmethod
|
||||
def open_connection(self) -> None:
|
||||
"""Open the socket."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def address(self) -> str:
|
||||
"""Get the location of the socket."""
|
||||
|
||||
|
||||
class MySQLUnixSocket(MySQLSocket):
|
||||
"""MySQL socket class using UNIX sockets.
|
||||
|
||||
Opens a connection through the UNIX socket of the MySQL Server.
|
||||
"""
|
||||
|
||||
def __init__(self, unix_socket: str = "/tmp/mysql.sock") -> None:
|
||||
super().__init__()
|
||||
self.unix_socket: str = unix_socket
|
||||
self._address: str = unix_socket
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
return self._address
|
||||
|
||||
def open_connection(self) -> None:
|
||||
try:
|
||||
self.sock = socket.socket(
|
||||
# pylint: disable=no-member
|
||||
socket.AF_UNIX,
|
||||
socket.SOCK_STREAM,
|
||||
)
|
||||
self.sock.settimeout(self._connection_timeout)
|
||||
self.sock.connect(self.unix_socket)
|
||||
except (socket.timeout, TimeoutError) as err:
|
||||
raise ConnectionTimeoutError(
|
||||
errno=2002,
|
||||
values=(
|
||||
self.address,
|
||||
_strioerror(err),
|
||||
),
|
||||
) from err
|
||||
except IOError as err:
|
||||
raise InterfaceError(
|
||||
errno=2002, values=(self.address, _strioerror(err))
|
||||
) from err
|
||||
except Exception as err:
|
||||
raise InterfaceError(str(err)) from err
|
||||
|
||||
def switch_to_ssl(
|
||||
self, *args: Any, **kwargs: Any # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
"""Switch the socket to use SSL."""
|
||||
warnings.warn(
|
||||
"SSL is disabled when using unix socket connections",
|
||||
Warning,
|
||||
)
|
||||
|
||||
|
||||
class MySQLTCPSocket(MySQLSocket):
|
||||
"""MySQL socket class using TCP/IP.
|
||||
|
||||
Opens a TCP/IP connection to the MySQL Server.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 3306,
|
||||
force_ipv6: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.server_host: str = host
|
||||
self.server_port: int = port
|
||||
self.force_ipv6: bool = force_ipv6
|
||||
self._family: int = 0
|
||||
self._address: str = f"{host}:{port}"
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
return self._address
|
||||
|
||||
def open_connection(self) -> None:
|
||||
"""Open the TCP/IP connection to the MySQL server."""
|
||||
# pylint: disable=no-member
|
||||
# Get address information
|
||||
addrinfo: Tuple[
|
||||
socket.AddressFamily,
|
||||
socket.SocketKind,
|
||||
int,
|
||||
str,
|
||||
Union[tuple[str, int], tuple[str, int, int, int], tuple[int, bytes]],
|
||||
] = (None, None, None, None, None)
|
||||
try:
|
||||
addrinfos = socket.getaddrinfo(
|
||||
self.server_host,
|
||||
self.server_port,
|
||||
0,
|
||||
socket.SOCK_STREAM,
|
||||
socket.SOL_TCP,
|
||||
)
|
||||
# If multiple results we favor IPv4, unless IPv6 was forced.
|
||||
for info in addrinfos:
|
||||
if self.force_ipv6 and info[0] == socket.AF_INET6:
|
||||
addrinfo = info
|
||||
break
|
||||
if info[0] == socket.AF_INET:
|
||||
addrinfo = info
|
||||
break
|
||||
if self.force_ipv6 and addrinfo[0] is None:
|
||||
raise InterfaceError(f"No IPv6 address found for {self.server_host}")
|
||||
if addrinfo[0] is None:
|
||||
addrinfo = addrinfos[0]
|
||||
except IOError as err:
|
||||
raise InterfaceError(
|
||||
errno=2003,
|
||||
values=(self.server_host, self.server_port, _strioerror(err)),
|
||||
) from err
|
||||
|
||||
(self._family, socktype, proto, _, sockaddr) = addrinfo
|
||||
|
||||
# Instantiate the socket and connect
|
||||
try:
|
||||
self.sock = socket.socket(self._family, socktype, proto)
|
||||
self.sock.settimeout(self._connection_timeout)
|
||||
self.sock.connect(sockaddr)
|
||||
except (socket.timeout, TimeoutError) as err:
|
||||
raise ConnectionTimeoutError(
|
||||
errno=2003,
|
||||
values=(
|
||||
self.server_host,
|
||||
self.server_port,
|
||||
_strioerror(err),
|
||||
),
|
||||
) from err
|
||||
except IOError as err:
|
||||
raise InterfaceError(
|
||||
errno=2003,
|
||||
values=(self.server_host, self.server_port, _strioerror(err)),
|
||||
) from err
|
||||
except Exception as err:
|
||||
raise InterfaceError(str(err)) from err
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
@@ -0,0 +1,77 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Constants used by the opentelemetry instrumentation implementation."""
|
||||
# mypy: disable-error-code="no-redef,assignment"
|
||||
|
||||
# pylint: disable=unused-import
|
||||
OTEL_ENABLED = True
|
||||
try:
|
||||
# try to load otel from the system
|
||||
from opentelemetry import trace # check api
|
||||
from opentelemetry.sdk.trace import TracerProvider # check sdk
|
||||
from opentelemetry.semconv.trace import SpanAttributes # check semconv
|
||||
except ImportError:
|
||||
OTEL_ENABLED = False
|
||||
|
||||
|
||||
OPTION_CNX_SPAN = "_span"
|
||||
"""
|
||||
Connection option name used to inject the connection span.
|
||||
This connection option name must not be used, is reserved.
|
||||
"""
|
||||
|
||||
OPTION_CNX_TRACER = "_tracer"
|
||||
"""
|
||||
Connection option name used to inject the opentelemetry tracer.
|
||||
This connection option name must not be used, is reserved.
|
||||
"""
|
||||
|
||||
CONNECTION_SPAN_NAME = "connection"
|
||||
"""
|
||||
Connection span name to be used by the instrumentor.
|
||||
"""
|
||||
|
||||
FIRST_SUPPORTED_VERSION = "8.1.0"
|
||||
"""
|
||||
First mysql-connector-python version to support opentelemetry instrumentation.
|
||||
"""
|
||||
|
||||
TRACEPARENT_HEADER_NAME = "traceparent"
|
||||
|
||||
DB_SYSTEM = "mysql"
|
||||
DEFAULT_THREAD_NAME = "main"
|
||||
DEFAULT_THREAD_ID = 0
|
||||
|
||||
# Reference: https://github.com/open-telemetry/opentelemetry-specification/blob/main/
|
||||
# specification/trace/semantic_conventions/span-general.md
|
||||
NET_SOCK_FAMILY = "net.sock.family"
|
||||
NET_SOCK_PEER_ADDR = "net.sock.peer.addr"
|
||||
NET_SOCK_PEER_PORT = "net.sock.peer.port"
|
||||
NET_SOCK_HOST_ADDR = "net.sock.host.addr"
|
||||
NET_SOCK_HOST_PORT = "net.sock.host.port"
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Trace context propagation utilities."""
|
||||
# mypy: disable-error-code="no-redef"
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from .constants import OTEL_ENABLED, TRACEPARENT_HEADER_NAME
|
||||
|
||||
if OTEL_ENABLED:
|
||||
# pylint: disable=import-error
|
||||
# load otel from the system
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.trace.span import format_span_id, format_trace_id
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
def build_traceparent_header(span: Any) -> str:
|
||||
"""Build a traceparent header according to the provided span.
|
||||
|
||||
The context information from the provided span is used to build the traceparent
|
||||
header that will be propagated to the MySQL server. For particulars regarding
|
||||
the header creation, refer to [1].
|
||||
|
||||
This method assumes version 0 of the W3C specification.
|
||||
|
||||
Args:
|
||||
span (opentelemetry.trace.span.Span): current span in trace.
|
||||
|
||||
Returns:
|
||||
traceparent_header (str): HTTP header field that identifies requests in a
|
||||
tracing system.
|
||||
|
||||
References:
|
||||
[1]: https://www.w3.org/TR/trace-context/#traceparent-header
|
||||
"""
|
||||
# pylint: disable=possibly-used-before-assignment
|
||||
ctx = span.get_span_context()
|
||||
|
||||
version = "00" # version 0 of the W3C specification
|
||||
trace_id = format_trace_id(ctx.trace_id)
|
||||
span_id = format_span_id(ctx.span_id)
|
||||
trace_flags = "00" # sampled flag is off
|
||||
|
||||
return "-".join([version, trace_id, span_id, trace_flags])
|
||||
|
||||
|
||||
def with_context_propagation(method: Callable) -> Callable:
|
||||
"""Perform trace context propagation.
|
||||
|
||||
The trace context is propagated via query attributes. The `traceparent` header
|
||||
from W3C specification [1] is used, in this sense, the attribute name is
|
||||
`traceparent` (is RESERVED, avoid using it), and its value is built as per
|
||||
instructed in [1].
|
||||
|
||||
If opentelemetry API/SDK is unavailable or there is no recording span,
|
||||
trace context propagation is skipped.
|
||||
|
||||
References:
|
||||
[1]: https://www.w3.org/TR/trace-context/#traceparent-header
|
||||
"""
|
||||
|
||||
def wrapper(cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any) -> Any:
|
||||
"""Context propagation decorator."""
|
||||
# pylint: disable=possibly-used-before-assignment
|
||||
if not OTEL_ENABLED or not cnx.otel_context_propagation:
|
||||
return method(cnx, *args, **kwargs)
|
||||
|
||||
current_span = trace.get_current_span()
|
||||
tp_header = None
|
||||
if current_span.is_recording():
|
||||
tp_header = build_traceparent_header(current_span)
|
||||
cnx.query_attrs_append(value=(TRACEPARENT_HEADER_NAME, tp_header))
|
||||
|
||||
try:
|
||||
result = method(cnx, *args, **kwargs)
|
||||
finally:
|
||||
if tp_header is not None:
|
||||
cnx.query_attrs_remove(name=TRACEPARENT_HEADER_NAME)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,687 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""MySQL instrumentation supporting mysql-connector."""
|
||||
# mypy: disable-error-code="no-redef"
|
||||
# pylint: disable=protected-access,global-statement,invalid-name,unused-argument
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import re
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import nullcontext
|
||||
from typing import TYPE_CHECKING, Any, Callable, Collection, Dict, Optional, Union
|
||||
|
||||
# pylint: disable=cyclic-import
|
||||
if TYPE_CHECKING:
|
||||
# `TYPE_CHECKING` is always False at run time, hence circular import
|
||||
# will not happen at run time (no error happens whatsoever).
|
||||
# Since pylint is a static checker it happens that `TYPE_CHECKING`
|
||||
# is True when analyzing the code which makes pylint believe there
|
||||
# is a circular import issue when there isn't.
|
||||
|
||||
from ..abstracts import MySQLConnectionAbstract, MySQLCursorAbstract
|
||||
from ..pooling import PooledMySQLConnection
|
||||
|
||||
from ... import connector
|
||||
from ..constants import CNX_POOL_ARGS, DEFAULT_CONFIGURATION
|
||||
from ..logger import logger
|
||||
from ..version import VERSION_TEXT
|
||||
|
||||
try:
|
||||
# pylint: disable=unused-import
|
||||
# try to load otel from the system
|
||||
from opentelemetry import trace # check api
|
||||
from opentelemetry.sdk.trace import TracerProvider # check sdk
|
||||
from opentelemetry.semconv.trace import SpanAttributes # check semconv
|
||||
except ImportError as missing_dependencies_err:
|
||||
raise connector.errors.ProgrammingError(
|
||||
"OpenTelemetry installation not found. You must install the API and SDK."
|
||||
) from missing_dependencies_err
|
||||
|
||||
|
||||
from .constants import (
|
||||
CONNECTION_SPAN_NAME,
|
||||
DB_SYSTEM,
|
||||
DEFAULT_THREAD_ID,
|
||||
DEFAULT_THREAD_NAME,
|
||||
FIRST_SUPPORTED_VERSION,
|
||||
NET_SOCK_FAMILY,
|
||||
NET_SOCK_HOST_ADDR,
|
||||
NET_SOCK_HOST_PORT,
|
||||
NET_SOCK_PEER_ADDR,
|
||||
NET_SOCK_PEER_PORT,
|
||||
OPTION_CNX_SPAN,
|
||||
OPTION_CNX_TRACER,
|
||||
)
|
||||
|
||||
leading_comment_remover: re.Pattern = re.compile(r"^/\*.*?\*/")
|
||||
|
||||
|
||||
def record_exception_event(span: trace.Span, exc: Optional[Exception]) -> None:
|
||||
"""Records an exeception event."""
|
||||
if not span or not span.is_recording() or not exc:
|
||||
return
|
||||
|
||||
span.set_status(trace.Status(trace.StatusCode.ERROR))
|
||||
span.record_exception(exc)
|
||||
|
||||
|
||||
def end_span(span: trace.Span) -> None:
|
||||
"""Ends span."""
|
||||
if not span or not span.is_recording():
|
||||
return
|
||||
|
||||
span.end()
|
||||
|
||||
|
||||
def get_operation_name(operation: str) -> str:
|
||||
"""Parse query to extract operation name."""
|
||||
if operation and isinstance(operation, str):
|
||||
# Strip leading comments so we get the operation name.
|
||||
return leading_comment_remover.sub("", operation).split()[0]
|
||||
return ""
|
||||
|
||||
|
||||
def set_connection_span_attrs(
|
||||
cnx: Optional["MySQLConnectionAbstract"],
|
||||
cnx_span: trace.Span,
|
||||
cnx_kwargs: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Defines connection span attributes. If `cnx` is None then we use `cnx_kwargs`
|
||||
to get basic net information. Basic net attributes are defined such as:
|
||||
|
||||
* DB_SYSTEM
|
||||
* NET_TRANSPORT
|
||||
* NET_SOCK_FAMILY
|
||||
|
||||
Socket-level attributes [*] are also defined [**].
|
||||
|
||||
[*]: Socket-level attributes identify peer and host that are directly connected to
|
||||
each other. Since instrumentations may have limited knowledge on network
|
||||
information, instrumentations SHOULD populate such attributes to the best of
|
||||
their knowledge when populate them at all.
|
||||
|
||||
[**]: `CMySQLConnection` connections have no access to socket-level
|
||||
details so socket-level attributes aren't included. `MySQLConnection`
|
||||
connections, on the other hand, do include socket-level attributes.
|
||||
|
||||
References:
|
||||
[1]: https://github.com/open-telemetry/opentelemetry-specification/blob/main/\
|
||||
specification/trace/semantic_conventions/span-general.md
|
||||
"""
|
||||
# pylint: disable=broad-exception-caught
|
||||
if not cnx_span or not cnx_span.is_recording():
|
||||
return
|
||||
|
||||
if cnx_kwargs is None:
|
||||
cnx_kwargs = {}
|
||||
|
||||
is_tcp = not cnx._unix_socket if cnx else "unix_socket" not in cnx_kwargs
|
||||
|
||||
attrs: Dict[str, Any] = {
|
||||
SpanAttributes.DB_SYSTEM: DB_SYSTEM,
|
||||
SpanAttributes.NET_TRANSPORT: "ip_tcp" if is_tcp else "inproc",
|
||||
NET_SOCK_FAMILY: "inet" if is_tcp else "unix",
|
||||
}
|
||||
|
||||
# Only socket and tcp connections are supported.
|
||||
if is_tcp:
|
||||
attrs[SpanAttributes.NET_PEER_NAME] = (
|
||||
cnx._host if cnx else cnx_kwargs.get("host", DEFAULT_CONFIGURATION["host"])
|
||||
)
|
||||
attrs[SpanAttributes.NET_PEER_PORT] = (
|
||||
cnx._port if cnx else cnx_kwargs.get("port", DEFAULT_CONFIGURATION["port"])
|
||||
)
|
||||
|
||||
if hasattr(cnx, "_socket") and cnx._socket:
|
||||
try:
|
||||
(
|
||||
attrs[NET_SOCK_PEER_ADDR],
|
||||
sock_peer_port,
|
||||
) = cnx._socket.sock.getpeername()
|
||||
|
||||
(
|
||||
attrs[NET_SOCK_HOST_ADDR],
|
||||
attrs[NET_SOCK_HOST_PORT],
|
||||
) = cnx._socket.sock.getsockname()
|
||||
except Exception as sock_err:
|
||||
logger.warning("Connection socket is down %s", sock_err)
|
||||
else:
|
||||
if attrs[SpanAttributes.NET_PEER_PORT] != sock_peer_port:
|
||||
# NET_SOCK_PEER_PORT is recommended if different than net.peer.port
|
||||
# and if net.sock.peer.addr is set.
|
||||
attrs[NET_SOCK_PEER_PORT] = sock_peer_port
|
||||
else:
|
||||
# For Unix domain socket, net.sock.peer.addr attribute represents
|
||||
# destination name and net.peer.name SHOULD NOT be set.
|
||||
attrs[NET_SOCK_PEER_ADDR] = (
|
||||
cnx._unix_socket if cnx else cnx_kwargs.get("unix_socket")
|
||||
)
|
||||
|
||||
if hasattr(cnx, "_socket") and cnx._socket:
|
||||
try:
|
||||
attrs[NET_SOCK_HOST_ADDR] = cnx._socket.sock.getsockname()
|
||||
except Exception as sock_err:
|
||||
logger.warning("Connection socket is down %s", sock_err)
|
||||
|
||||
cnx_span.set_attributes(attrs)
|
||||
|
||||
|
||||
def with_cnx_span_attached(method: Callable) -> Callable:
|
||||
"""Attach the connection span while executing the connection method."""
|
||||
|
||||
def wrapper(cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any) -> Any:
|
||||
"""Connection span attacher decorator."""
|
||||
with trace.use_span(
|
||||
cnx._span, end_on_exit=False
|
||||
) if cnx._span and cnx._span.is_recording() else nullcontext():
|
||||
return method(cnx, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def with_cnx_query_span(method: Callable) -> Callable:
|
||||
"""Create a query span while executing the connection method."""
|
||||
|
||||
def wrapper(cnx: TracedMySQLConnection, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Query span creator decorator."""
|
||||
logger.info("Creating query span for connection.%s", method.__name__)
|
||||
|
||||
query_span_attributes: Dict = {
|
||||
SpanAttributes.DB_SYSTEM: DB_SYSTEM,
|
||||
SpanAttributes.DB_USER: cnx._user,
|
||||
SpanAttributes.THREAD_ID: DEFAULT_THREAD_ID,
|
||||
SpanAttributes.THREAD_NAME: DEFAULT_THREAD_NAME,
|
||||
"connection_type": cnx.get_wrapped_class(),
|
||||
}
|
||||
|
||||
with cnx._tracer.start_as_current_span(
|
||||
name=method.__name__.upper(),
|
||||
kind=trace.SpanKind.CLIENT,
|
||||
links=[trace.Link(cnx._span.get_span_context())],
|
||||
attributes=query_span_attributes,
|
||||
) if cnx._span and cnx._span.is_recording() else nullcontext():
|
||||
return method(cnx, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def with_cursor_query_span(method: Callable) -> Callable:
|
||||
"""Create a query span while executing the cursor method."""
|
||||
|
||||
def wrapper(cur: TracedMySQLCursor, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Query span creator decorator."""
|
||||
logger.info("Creating query span for cursor.%s", method.__name__)
|
||||
|
||||
connection: "MySQLConnectionAbstract" = (
|
||||
getattr(cur._wrapped, "_connection")
|
||||
if hasattr(cur._wrapped, "_connection")
|
||||
else getattr(cur._wrapped, "_cnx")
|
||||
)
|
||||
|
||||
query_span_attributes: Dict = {
|
||||
SpanAttributes.DB_SYSTEM: DB_SYSTEM,
|
||||
SpanAttributes.DB_USER: connection._user,
|
||||
SpanAttributes.THREAD_ID: DEFAULT_THREAD_ID,
|
||||
SpanAttributes.THREAD_NAME: DEFAULT_THREAD_NAME,
|
||||
"cursor_type": cur.get_wrapped_class(),
|
||||
}
|
||||
|
||||
with cur._tracer.start_as_current_span(
|
||||
name=get_operation_name(args[0]) or "SQL statement",
|
||||
kind=trace.SpanKind.CLIENT,
|
||||
links=[cur._connection_span_link],
|
||||
attributes=query_span_attributes,
|
||||
):
|
||||
return method(cur, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class BaseMySQLTracer(ABC):
|
||||
"""Base class that provides basic object wrapper functionality."""
|
||||
|
||||
@abstractmethod
|
||||
def __init__(self) -> None:
|
||||
"""Must be implemented by subclasses."""
|
||||
|
||||
def __getattr__(self, attr: str) -> Any:
|
||||
"""Gets an attribute.
|
||||
|
||||
Attributes defined in the wrapper object have higher precedence
|
||||
than those wrapped object equivalent. Attributes not found in
|
||||
the wrapper are then searched in the wrapped object.
|
||||
"""
|
||||
if attr in self.__dict__:
|
||||
# this object has it
|
||||
return getattr(self, attr)
|
||||
# proxy to the wrapped object
|
||||
return getattr(self._wrapped, attr)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
if "_wrapped" not in self.__dict__:
|
||||
self.__dict__["_wrapped"] = value
|
||||
return
|
||||
|
||||
if name in self.__dict__ or name == "autocommit":
|
||||
# this object has it
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
# proxy to the wrapped object
|
||||
self._wrapped.__setattr__(name, value)
|
||||
|
||||
def __enter__(self) -> Any:
|
||||
"""Magic method."""
|
||||
self._wrapped.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Magic method."""
|
||||
self._wrapped.__exit__(*args, **kwargs)
|
||||
|
||||
def get_wrapped_class(self) -> str:
|
||||
"""Gets the wrapped class name."""
|
||||
return self._wrapped.__class__.__name__
|
||||
|
||||
|
||||
class TracedMySQLCursor(BaseMySQLTracer):
|
||||
"""Wrapper class for a `MySQLCursor` or `CMySQLCursor` object."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
wrapped: "MySQLCursorAbstract",
|
||||
tracer: trace.Tracer,
|
||||
connection_span: trace.Span,
|
||||
):
|
||||
"""Constructor."""
|
||||
self._wrapped: "MySQLCursorAbstract" = wrapped
|
||||
self._tracer: trace.Tracer = tracer
|
||||
self._connection_span_link: trace.Link = trace.Link(
|
||||
connection_span.get_span_context()
|
||||
)
|
||||
|
||||
@with_cursor_query_span
|
||||
def execute(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.execute(*args, **kwargs)
|
||||
|
||||
@with_cursor_query_span
|
||||
def executemany(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.executemany(*args, **kwargs)
|
||||
|
||||
@with_cursor_query_span
|
||||
def callproc(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.callproc(*args, **kwargs)
|
||||
|
||||
|
||||
class TracedMySQLConnection(BaseMySQLTracer):
|
||||
"""Wrapper class for a `MySQLConnection` or `CMySQLConnection` object."""
|
||||
|
||||
def __init__(self, wrapped: "MySQLConnectionAbstract") -> None:
|
||||
"""Constructor."""
|
||||
self._wrapped: "MySQLConnectionAbstract" = wrapped
|
||||
|
||||
# call `sql_mode` so its value is cached internally and querying it does not
|
||||
# interfere when recording query span events later.
|
||||
_ = self._wrapped.sql_mode
|
||||
|
||||
def cursor(self, *args: Any, **kwargs: Any) -> TracedMySQLCursor:
|
||||
"""Wraps the object method."""
|
||||
return TracedMySQLCursor(
|
||||
wrapped=self._wrapped.cursor(*args, **kwargs),
|
||||
tracer=self._tracer,
|
||||
connection_span=self._span,
|
||||
)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_change_user(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_change_user(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def commit(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.commit(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def rollback(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.rollback(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_query(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_query(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_init_db(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_init_db(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_refresh(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_refresh(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_quit(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_quit(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_shutdown(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_shutdown(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_statistics(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_statistics(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_process_kill(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_process_kill(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_debug(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_debug(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_ping(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_ping(*args, **kwargs)
|
||||
|
||||
@property
|
||||
@with_cnx_query_span
|
||||
def database(self) -> str:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.database
|
||||
|
||||
@with_cnx_query_span
|
||||
def is_connected(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.is_connected(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def reset_session(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.reset_session(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def ping(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.ping(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def info_query(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.info_query(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_stmt_prepare(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_stmt_prepare(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_stmt_execute(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_stmt_execute(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_stmt_close(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_stmt_close(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_stmt_send_long_data(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_stmt_send_long_data(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_stmt_reset(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_stmt_reset(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_reset_connection(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_reset_connection(*args, **kwargs)
|
||||
|
||||
@property
|
||||
@with_cnx_query_span
|
||||
def time_zone(self) -> str:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.time_zone
|
||||
|
||||
@property
|
||||
@with_cnx_query_span
|
||||
def sql_mode(self) -> str:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.sql_mode
|
||||
|
||||
@property
|
||||
@with_cnx_query_span
|
||||
def autocommit(self) -> bool:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.autocommit
|
||||
|
||||
@autocommit.setter
|
||||
@with_cnx_query_span
|
||||
def autocommit(self, value: bool) -> None:
|
||||
"""Instrument method."""
|
||||
self._wrapped.autocommit = value
|
||||
|
||||
@with_cnx_query_span
|
||||
def set_charset_collation(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.set_charset_collation(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def start_transaction(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.start_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
def _instrument_connect(
|
||||
connect: Callable[..., Union["MySQLConnectionAbstract", "PooledMySQLConnection"]],
|
||||
tracer_provider: Optional[trace.TracerProvider] = None,
|
||||
) -> Callable[..., Union["MySQLConnectionAbstract", "PooledMySQLConnection"]]:
|
||||
"""Retrurn the instrumented version of `connect`."""
|
||||
|
||||
# let's preserve `connect` identity.
|
||||
@functools.wraps(connect)
|
||||
def wrapper(
|
||||
*args: Any, **kwargs: Any
|
||||
) -> Union["MySQLConnectionAbstract", "PooledMySQLConnection"]:
|
||||
"""Wraps the connection object returned by the method `connect`.
|
||||
|
||||
Instrumentation for PooledConnections is not supported.
|
||||
"""
|
||||
if any(key in kwargs for key in CNX_POOL_ARGS):
|
||||
logger.warning("Instrumentation for pooled connections not supported")
|
||||
return connect(*args, **kwargs)
|
||||
|
||||
tracer = trace.get_tracer(
|
||||
instrumenting_module_name="MySQL Connector/Python",
|
||||
instrumenting_library_version=VERSION_TEXT,
|
||||
tracer_provider=tracer_provider,
|
||||
)
|
||||
|
||||
# The connection span is passed in as an argument so the connection object can
|
||||
# keep a pointer to it.
|
||||
kwargs[OPTION_CNX_SPAN] = tracer.start_span(
|
||||
name=CONNECTION_SPAN_NAME, kind=trace.SpanKind.CLIENT
|
||||
)
|
||||
kwargs[OPTION_CNX_TRACER] = tracer
|
||||
|
||||
# attach connection span
|
||||
# pylint: disable=not-context-manager
|
||||
with trace.use_span(kwargs[OPTION_CNX_SPAN], end_on_exit=False) as cnx_span:
|
||||
# Add basic net information.
|
||||
set_connection_span_attrs(None, cnx_span, kwargs)
|
||||
|
||||
# Connection may fail at this point, in case it does, basic net info is already
|
||||
# included so the user can check the net configuration she/he provided.
|
||||
cnx = connect(*args, **kwargs)
|
||||
|
||||
# connection went ok, let's refine the net information.
|
||||
set_connection_span_attrs(cnx, cnx_span, kwargs) # type: ignore[arg-type]
|
||||
|
||||
return TracedMySQLConnection(
|
||||
wrapped=cnx, # type: ignore[return-value, arg-type]
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class MySQLInstrumentor:
|
||||
"""MySQL instrumentation supporting mysql-connector-python."""
|
||||
|
||||
_instance: Optional[MySQLInstrumentor] = None
|
||||
|
||||
def __new__(cls, *args: Any, **kwargs: Any) -> MySQLInstrumentor:
|
||||
"""Singlenton.
|
||||
|
||||
Restricts the instantiation to a singular instance.
|
||||
"""
|
||||
if cls._instance is None:
|
||||
# create instance
|
||||
cls._instance = object.__new__(cls, *args, **kwargs)
|
||||
# keep a pointer to the uninstrumented connect method
|
||||
setattr(cls._instance, "_original_connect", connector.connect)
|
||||
return cls._instance
|
||||
|
||||
def instrumentation_dependencies(self) -> Collection[str]:
|
||||
"""Return a list of python packages with versions
|
||||
that the will be instrumented (e.g., versions >= 8.1.0)."""
|
||||
return [f"mysql-connector-python >= {FIRST_SUPPORTED_VERSION}"]
|
||||
|
||||
def instrument(self, **kwargs: Any) -> None:
|
||||
"""Instrument the library.
|
||||
|
||||
Args:
|
||||
trace_module: reference to the 'trace' module from opentelemetry.
|
||||
tracer_provider (optional): TracerProvider instance.
|
||||
|
||||
NOTE: Instrumentation for pooled connections not supported.
|
||||
"""
|
||||
if connector.connect != getattr(self, "_original_connect"):
|
||||
logger.warning("MySQL Connector/Python module already instrumented.")
|
||||
return
|
||||
connector.connect = _instrument_connect(
|
||||
connect=getattr(self, "_original_connect"),
|
||||
tracer_provider=kwargs.get("tracer_provider"),
|
||||
)
|
||||
|
||||
def instrument_connection(
|
||||
self,
|
||||
connection: "MySQLConnectionAbstract",
|
||||
tracer_provider: Optional[trace.TracerProvider] = None,
|
||||
) -> "MySQLConnectionAbstract":
|
||||
"""Enable instrumentation in a MySQL connection.
|
||||
|
||||
Args:
|
||||
connection: uninstrumented connection instance.
|
||||
trace_module: reference to the 'trace' module from opentelemetry.
|
||||
tracer_provider (optional): TracerProvider instance.
|
||||
|
||||
Returns:
|
||||
connection: instrumented connection instace.
|
||||
|
||||
NOTE: Instrumentation for pooled connections not supported.
|
||||
"""
|
||||
if isinstance(connection, TracedMySQLConnection):
|
||||
logger.warning("Connection already instrumented.")
|
||||
return connection
|
||||
|
||||
if not hasattr(connection, "_span") or not hasattr(connection, "_tracer"):
|
||||
logger.warning(
|
||||
"Instrumentation for class %s not supported.",
|
||||
connection.__class__.__name__,
|
||||
)
|
||||
return connection
|
||||
|
||||
tracer = trace.get_tracer(
|
||||
instrumenting_module_name="MySQL Connector/Python",
|
||||
instrumenting_library_version=VERSION_TEXT,
|
||||
tracer_provider=tracer_provider,
|
||||
)
|
||||
connection._span = tracer.start_span(
|
||||
name=CONNECTION_SPAN_NAME, kind=trace.SpanKind.CLIENT
|
||||
)
|
||||
connection._tracer = tracer
|
||||
|
||||
set_connection_span_attrs(connection, connection._span)
|
||||
|
||||
return TracedMySQLConnection(wrapped=connection) # type: ignore[return-value]
|
||||
|
||||
def uninstrument(self, **kwargs: Any) -> None:
|
||||
"""Uninstrument the library."""
|
||||
# pylint: disable=unused-argument
|
||||
if connector.connect == getattr(self, "_original_connect"):
|
||||
logger.warning("MySQL Connector/Python module already uninstrumented.")
|
||||
return
|
||||
connector.connect = getattr(self, "_original_connect")
|
||||
|
||||
def uninstrument_connection(
|
||||
self, connection: "MySQLConnectionAbstract"
|
||||
) -> "MySQLConnectionAbstract":
|
||||
"""Disable instrumentation in a MySQL connection.
|
||||
|
||||
Args:
|
||||
connection: instrumented connection instance.
|
||||
|
||||
Returns:
|
||||
connection: uninstrumented connection instace.
|
||||
|
||||
NOTE: Instrumentation for pooled connections not supported.
|
||||
"""
|
||||
if not hasattr(connection, "_span"):
|
||||
logger.warning(
|
||||
"Uninstrumentation for class %s not supported.",
|
||||
connection.__class__.__name__,
|
||||
)
|
||||
return connection
|
||||
|
||||
if not isinstance(connection, TracedMySQLConnection):
|
||||
logger.warning("Connection already uninstrumented.")
|
||||
return connection
|
||||
|
||||
# stop connection span recording
|
||||
if connection._span and connection._span.is_recording():
|
||||
connection._span.end()
|
||||
connection._span = None
|
||||
|
||||
return connection._wrapped
|
||||
@@ -0,0 +1,367 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="attr-defined"
|
||||
|
||||
"""Implements parser to parse MySQL option files."""
|
||||
|
||||
import ast
|
||||
import codecs
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
|
||||
from configparser import ConfigParser as SafeConfigParser, MissingSectionHeaderError
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from .constants import CNX_POOL_ARGS, DEFAULT_CONFIGURATION
|
||||
|
||||
DEFAULT_EXTENSIONS: Dict[str, Tuple[str, ...]] = {
|
||||
"nt": ("ini", "cnf"),
|
||||
"posix": ("cnf",),
|
||||
}
|
||||
|
||||
|
||||
def read_option_files(**config: Union[str, List[str]]) -> Dict[str, Any]:
|
||||
"""
|
||||
Read option files for connection parameters.
|
||||
|
||||
Checks if connection arguments contain option file arguments, and then
|
||||
reads option files accordingly.
|
||||
"""
|
||||
if "option_files" in config:
|
||||
try:
|
||||
if isinstance(config["option_groups"], str):
|
||||
config["option_groups"] = [config["option_groups"]]
|
||||
groups = config["option_groups"]
|
||||
del config["option_groups"]
|
||||
except KeyError:
|
||||
groups = ["client", "connector_python"]
|
||||
|
||||
if isinstance(config["option_files"], str):
|
||||
config["option_files"] = [config["option_files"]]
|
||||
option_parser = MySQLOptionsParser(
|
||||
list(config["option_files"]), keep_dashes=False
|
||||
)
|
||||
del config["option_files"]
|
||||
|
||||
config_from_file: Dict[str, Any] = (
|
||||
option_parser.get_groups_as_dict_with_priority(*groups)
|
||||
)
|
||||
config_options: Dict[str, Tuple[str, int, str]] = {}
|
||||
for group in groups:
|
||||
try:
|
||||
for option, value in config_from_file[group].items():
|
||||
value += (group,)
|
||||
try:
|
||||
if option == "socket":
|
||||
option = "unix_socket"
|
||||
option_parser.set(group, "unix_socket", value[0])
|
||||
|
||||
if option not in CNX_POOL_ARGS and option != "failover":
|
||||
_ = DEFAULT_CONFIGURATION[option]
|
||||
|
||||
if (
|
||||
option not in config_options
|
||||
or config_options[option][1] <= value[1]
|
||||
):
|
||||
config_options[option] = value
|
||||
except KeyError:
|
||||
if group == "connector_python":
|
||||
raise AttributeError(
|
||||
f"Unsupported argument '{option}'"
|
||||
) from None
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
for option, values in config_options.items():
|
||||
value, _, section = values
|
||||
if (
|
||||
option not in config
|
||||
and option_parser.has_section(section)
|
||||
and option_parser.has_option(section, option)
|
||||
):
|
||||
if option in ("password", "passwd"): # keep the value as string
|
||||
config[option] = str(value)
|
||||
else:
|
||||
try:
|
||||
config[option] = ast.literal_eval(value)
|
||||
except (ValueError, TypeError, SyntaxError):
|
||||
config[option] = value
|
||||
if "socket" in config:
|
||||
config["unix_socket"] = config.pop("socket")
|
||||
return config
|
||||
|
||||
|
||||
class MySQLOptionsParser(SafeConfigParser):
|
||||
"""This class implements methods to parse MySQL option files"""
|
||||
|
||||
def __init__(
|
||||
self, files: Optional[Union[List[str], str]] = None, keep_dashes: bool = True
|
||||
) -> None:
|
||||
"""Initialize
|
||||
|
||||
If defaults is True, default option files are read first
|
||||
|
||||
Raises ValueError if defaults is set to True but defaults files
|
||||
cannot be found.
|
||||
"""
|
||||
|
||||
# Regular expression to allow options with no value(For Python v2.6)
|
||||
self.optcre: re.Pattern = re.compile(
|
||||
r"(?P<option>[^:=\s][^:=]*)"
|
||||
r"\s*(?:"
|
||||
r"(?P<vi>[:=])\s*"
|
||||
r"(?P<value>.*))?$"
|
||||
)
|
||||
|
||||
self._options_dict: Dict[str, Dict[str, Tuple[str, int]]] = {}
|
||||
|
||||
SafeConfigParser.__init__(self, strict=False)
|
||||
|
||||
self.default_extension: Tuple[str, ...] = DEFAULT_EXTENSIONS[os.name]
|
||||
self.keep_dashes: bool = keep_dashes
|
||||
|
||||
if not files:
|
||||
raise ValueError("files argument should be given")
|
||||
self.files: List[str] = [files] if isinstance(files, str) else files
|
||||
|
||||
self._parse_options(list(self.files))
|
||||
self._sections: Dict[str, Dict[str, str]] = self.get_groups_as_dict()
|
||||
|
||||
def optionxform(self, optionstr: str) -> str:
|
||||
"""Converts option strings
|
||||
|
||||
Converts option strings to lower case and replaces dashes(-) with
|
||||
underscores(_) if keep_dashes variable is set.
|
||||
"""
|
||||
if not self.keep_dashes:
|
||||
optionstr = optionstr.replace("-", "_")
|
||||
return optionstr.lower()
|
||||
|
||||
def _parse_options(self, files: List[str]) -> None:
|
||||
"""Parse options from files given as arguments.
|
||||
This method checks for !include or !inculdedir directives and if there
|
||||
is any, those files included by these directives are also parsed
|
||||
for options.
|
||||
|
||||
Raises ValueError if any of the included or file given in arguments
|
||||
is not readable.
|
||||
"""
|
||||
initial_files = files[:]
|
||||
files = []
|
||||
index = 0
|
||||
err_msg = "Option file '{0}' being included again in file '{1}'"
|
||||
|
||||
for file_ in initial_files:
|
||||
try:
|
||||
if file_ in initial_files[index + 1 :]:
|
||||
raise ValueError(
|
||||
f"Same option file '{file_}' occurring more "
|
||||
"than once in the list"
|
||||
)
|
||||
with open(file_, "r", encoding="utf-8") as op_file:
|
||||
for line in op_file.readlines():
|
||||
if line.startswith("!includedir"):
|
||||
_, dir_path = line.split(None, 1)
|
||||
dir_path = dir_path.strip()
|
||||
for entry in os.listdir(dir_path):
|
||||
entry = os.path.join(dir_path, entry)
|
||||
if entry in files:
|
||||
raise ValueError(err_msg.format(entry, file_))
|
||||
if os.path.isfile(entry) and entry.endswith(
|
||||
self.default_extension
|
||||
):
|
||||
files.append(entry)
|
||||
|
||||
elif line.startswith("!include"):
|
||||
_, filename = line.split(None, 1)
|
||||
filename = filename.strip()
|
||||
if filename in files:
|
||||
raise ValueError(err_msg.format(filename, file_))
|
||||
files.append(filename)
|
||||
|
||||
index += 1
|
||||
files.append(file_)
|
||||
except IOError as err:
|
||||
raise ValueError(f"Failed reading file '{file_}': {err}") from err
|
||||
|
||||
read_files = self.read(files)
|
||||
not_read_files = set(files) - set(read_files)
|
||||
if not_read_files:
|
||||
raise ValueError(f"File(s) {', '.join(not_read_files)} could not be read.")
|
||||
|
||||
def read( # type: ignore[override]
|
||||
self, filenames: Union[str, List[str]], encoding: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""Read and parse a filename or a list of filenames.
|
||||
|
||||
Overridden from ConfigParser and modified so as to allow options
|
||||
which are not inside any section header
|
||||
|
||||
Return list of successfully read files.
|
||||
"""
|
||||
if isinstance(filenames, str):
|
||||
filenames = [filenames]
|
||||
read_ok = []
|
||||
for priority, filename in enumerate(filenames):
|
||||
try:
|
||||
out_file = io.StringIO()
|
||||
with codecs.open(filename, encoding="utf-8") as in_file:
|
||||
for line in in_file:
|
||||
line = line.strip()
|
||||
# Skip lines that begin with "!includedir" or "!include"
|
||||
if line.startswith("!include"):
|
||||
continue
|
||||
|
||||
match_obj = self.optcre.match(line)
|
||||
if not self.SECTCRE.match(line) and match_obj:
|
||||
optname, delimiter, optval = match_obj.group(
|
||||
"option", "vi", "value"
|
||||
)
|
||||
if optname and not optval and not delimiter:
|
||||
out_file.write(f"{line}=\n")
|
||||
else:
|
||||
out_file.write(f"{line}\n")
|
||||
else:
|
||||
out_file.write(f"{line}\n")
|
||||
out_file.seek(0)
|
||||
except IOError:
|
||||
continue
|
||||
try:
|
||||
self._read(out_file, filename)
|
||||
for group in self._sections.keys():
|
||||
try:
|
||||
self._options_dict[group]
|
||||
except KeyError:
|
||||
self._options_dict[group] = {}
|
||||
for option, value in self._sections[group].items():
|
||||
self._options_dict[group][option] = (value, priority)
|
||||
|
||||
self._sections = self._dict()
|
||||
|
||||
except MissingSectionHeaderError:
|
||||
self._read(out_file, filename)
|
||||
out_file.close()
|
||||
read_ok.append(filename)
|
||||
return read_ok
|
||||
|
||||
def get_groups(self, *args: str) -> Dict[str, str]:
|
||||
"""Returns options as a dictionary.
|
||||
|
||||
Returns options from all the groups specified as arguments, returns
|
||||
the options from all groups if no argument provided. Options are
|
||||
overridden when they are found in the next group.
|
||||
|
||||
Returns a dictionary
|
||||
"""
|
||||
if not args:
|
||||
args = tuple(self._options_dict.keys())
|
||||
|
||||
options = {}
|
||||
priority: Dict[str, int] = {}
|
||||
for group in args:
|
||||
try:
|
||||
for option, value in [
|
||||
(
|
||||
key,
|
||||
value,
|
||||
)
|
||||
for key, value in self._options_dict[group].items()
|
||||
if key != "__name__" and not key.startswith("!")
|
||||
]:
|
||||
if option not in options or priority[option] <= value[1]:
|
||||
priority[option] = value[1]
|
||||
options[option] = value[0]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return options
|
||||
|
||||
def get_groups_as_dict_with_priority(
|
||||
self, *args: str
|
||||
) -> Dict[str, Dict[str, Tuple[str, int]]]:
|
||||
"""Returns options as dictionary of dictionaries.
|
||||
|
||||
Returns options from all the groups specified as arguments. For each
|
||||
group the option are contained in a dictionary. The order in which
|
||||
the groups are specified is unimportant. Also options are not
|
||||
overridden in between the groups.
|
||||
|
||||
The value is a tuple with two elements, first being the actual value
|
||||
and second is the priority of the value which is higher for a value
|
||||
read from a higher priority file.
|
||||
|
||||
Returns an dictionary of dictionaries
|
||||
"""
|
||||
if not args:
|
||||
args = tuple(self._options_dict.keys())
|
||||
|
||||
options = {}
|
||||
for group in args:
|
||||
try:
|
||||
options[group] = dict(
|
||||
(
|
||||
key,
|
||||
value,
|
||||
)
|
||||
for key, value in self._options_dict[group].items()
|
||||
if key != "__name__" and not key.startswith("!")
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
return options
|
||||
|
||||
def get_groups_as_dict(self, *args: str) -> Dict[str, Dict[str, str]]:
|
||||
"""Returns options as dictionary of dictionaries.
|
||||
|
||||
Returns options from all the groups specified as arguments. For each
|
||||
group the option are contained in a dictionary. The order in which
|
||||
the groups are specified is unimportant. Also options are not
|
||||
overridden in between the groups.
|
||||
|
||||
Returns an dictionary of dictionaries
|
||||
"""
|
||||
if not args:
|
||||
args = tuple(self._options_dict.keys())
|
||||
|
||||
options = {}
|
||||
for group in args:
|
||||
try:
|
||||
options[group] = dict(
|
||||
(
|
||||
key,
|
||||
value[0],
|
||||
)
|
||||
for key, value in self._options_dict[group].items()
|
||||
if key != "__name__" and not key.startswith("!")
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return options
|
||||
@@ -0,0 +1,160 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Base Authentication Plugin class."""
|
||||
|
||||
import importlib
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING, Any, Optional, Type
|
||||
|
||||
from ..errors import NotSupportedError, ProgrammingError
|
||||
from ..logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
DEFAULT_PLUGINS_PKG = "mysql.connector.plugins"
|
||||
|
||||
|
||||
class MySQLAuthPlugin(ABC):
|
||||
"""Authorization plugin interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
ssl_enabled: bool = False,
|
||||
) -> None:
|
||||
"""Constructor."""
|
||||
self._username: str = "" if username is None else username
|
||||
self._password: str = "" if password is None else password
|
||||
self._ssl_enabled: bool = ssl_enabled
|
||||
|
||||
@property
|
||||
def ssl_enabled(self) -> bool:
|
||||
"""Signals whether or not SSL is enabled."""
|
||||
return self._ssl_enabled
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
|
||||
@abstractmethod
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Make the client's authorization response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
"""
|
||||
|
||||
def auth_more_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth more data` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Authentication method data (from a packet representing
|
||||
an `auth more data` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth communication.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth communication.
|
||||
"""
|
||||
|
||||
|
||||
@lru_cache(maxsize=10, typed=False)
|
||||
def get_auth_plugin(
|
||||
plugin_name: str,
|
||||
auth_plugin_class: Optional[str] = None,
|
||||
) -> Type[MySQLAuthPlugin]:
|
||||
"""Return authentication class based on plugin name
|
||||
|
||||
This function returns the class for the authentication plugin plugin_name.
|
||||
The returned class is a subclass of BaseAuthPlugin.
|
||||
|
||||
Args:
|
||||
plugin_name (str): Authentication plugin name.
|
||||
auth_plugin_class (str): Authentication plugin class name.
|
||||
|
||||
Raises:
|
||||
NotSupportedError: When plugin_name is not supported.
|
||||
|
||||
Returns:
|
||||
Subclass of `MySQLAuthPlugin`.
|
||||
"""
|
||||
package = DEFAULT_PLUGINS_PKG
|
||||
if plugin_name:
|
||||
try:
|
||||
logger.info("package: %s", package)
|
||||
logger.info("plugin_name: %s", plugin_name)
|
||||
plugin_module = importlib.import_module(f".{plugin_name}", package)
|
||||
if not auth_plugin_class or not hasattr(plugin_module, auth_plugin_class):
|
||||
auth_plugin_class = plugin_module.AUTHENTICATION_PLUGIN_CLASS
|
||||
logger.info("AUTHENTICATION_PLUGIN_CLASS: %s", auth_plugin_class)
|
||||
return getattr(plugin_module, auth_plugin_class)
|
||||
except ModuleNotFoundError as err:
|
||||
logger.warning("Requested Module was not found: %s", err)
|
||||
except ValueError as err:
|
||||
raise ProgrammingError(f"Invalid module name: {err}") from err
|
||||
raise NotSupportedError(f"Authentication plugin '{plugin_name}' is not supported")
|
||||
+576
@@ -0,0 +1,576 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="str-bytes-safe,misc"
|
||||
|
||||
"""Kerberos Authentication Plugin."""
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import struct
|
||||
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Optional, Tuple
|
||||
|
||||
from ..authentication import ERR_STATUS
|
||||
from ..errors import InterfaceError, ProgrammingError
|
||||
from ..logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
import gssapi
|
||||
except ImportError:
|
||||
gssapi = None
|
||||
if os.name != "nt":
|
||||
raise ProgrammingError(
|
||||
"Module gssapi is required for GSSAPI authentication "
|
||||
"mechanism but was not found. Unable to authenticate "
|
||||
"with the server"
|
||||
) from None
|
||||
|
||||
try:
|
||||
import sspi
|
||||
import sspicon
|
||||
except ImportError:
|
||||
sspi = None
|
||||
sspicon = None
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = (
|
||||
"MySQLSSPIKerberosAuthPlugin" if os.name == "nt" else "MySQLKerberosAuthPlugin"
|
||||
)
|
||||
|
||||
|
||||
class MySQLBaseKerberosAuthPlugin(MySQLAuthPlugin):
|
||||
"""Base class for the MySQL Kerberos authentication plugin."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_kerberos_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def auth_continue(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge: the challenge for the negotiation.
|
||||
|
||||
Returns:
|
||||
tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
logger.debug("# auth_data: %s", auth_data)
|
||||
response = self.auth_response(auth_data, ignore_auth_data=False, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
packet = sock.recv()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
if packet != ERR_STATUS:
|
||||
rcode_size = 5 # Reader size for the response status code
|
||||
logger.debug("# Continue with GSSAPI authentication")
|
||||
logger.debug("# Response header: %s", packet[: rcode_size + 1])
|
||||
logger.debug("# Response size: %s", len(packet))
|
||||
logger.debug("# Negotiate a service request")
|
||||
complete = False
|
||||
tries = 0
|
||||
|
||||
while not complete and tries < 5:
|
||||
logger.debug("%s Attempt %s %s", "-" * 20, tries + 1, "-" * 20)
|
||||
logger.debug("<< Server response: %s", packet)
|
||||
logger.debug("# Response code: %s", packet[: rcode_size + 1])
|
||||
token, complete = self.auth_continue(packet[rcode_size:])
|
||||
if token:
|
||||
sock.send(token)
|
||||
if complete:
|
||||
break
|
||||
packet = sock.recv()
|
||||
|
||||
logger.debug(">> Response to server: %s", token)
|
||||
tries += 1
|
||||
|
||||
if not complete:
|
||||
raise InterfaceError(
|
||||
f"Unable to fulfill server request after {tries} "
|
||||
f"attempts. Last server response: {packet}"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Last response from server: %s length: %d",
|
||||
packet,
|
||||
len(packet),
|
||||
)
|
||||
|
||||
# Receive OK packet from server.
|
||||
packet = sock.recv()
|
||||
logger.debug("<< Ok packet from server: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
|
||||
|
||||
# pylint: disable=c-extension-no-member,no-member
|
||||
class MySQLKerberosAuthPlugin(MySQLBaseKerberosAuthPlugin):
|
||||
"""Implement the MySQL Kerberos authentication plugin."""
|
||||
|
||||
context: Optional[gssapi.SecurityContext] = None
|
||||
|
||||
@staticmethod
|
||||
def get_user_from_credentials() -> str:
|
||||
"""Get user from credentials without realm."""
|
||||
try:
|
||||
creds = gssapi.Credentials(usage="initiate")
|
||||
user = str(creds.name)
|
||||
if user.find("@") != -1:
|
||||
user, _ = user.split("@", 1)
|
||||
return user
|
||||
except gssapi.raw.misc.GSSError:
|
||||
return getpass.getuser()
|
||||
|
||||
@staticmethod
|
||||
def get_store() -> dict:
|
||||
"""Get a credentials store dictionary.
|
||||
|
||||
Returns:
|
||||
dict: Credentials store dictionary with the krb5 ccache name.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If 'KRB5CCNAME' environment variable is empty.
|
||||
"""
|
||||
krb5ccname = os.environ.get(
|
||||
"KRB5CCNAME",
|
||||
(
|
||||
f"/tmp/krb5cc_{os.getuid()}"
|
||||
if os.name == "posix"
|
||||
else Path("%TEMP%").joinpath("krb5cc")
|
||||
),
|
||||
)
|
||||
if not krb5ccname:
|
||||
raise InterfaceError(
|
||||
"The 'KRB5CCNAME' environment variable is set to empty"
|
||||
)
|
||||
logger.debug("Using krb5 ccache name: FILE:%s", krb5ccname)
|
||||
store = {b"ccache": f"FILE:{krb5ccname}".encode("utf-8")}
|
||||
return store
|
||||
|
||||
def _acquire_cred_with_password(self, upn: str) -> gssapi.raw.creds.Creds:
|
||||
"""Acquire and store credentials through provided password.
|
||||
|
||||
Args:
|
||||
upn (str): User Principal Name.
|
||||
|
||||
Returns:
|
||||
gssapi.raw.creds.Creds: GSSAPI credentials.
|
||||
"""
|
||||
logger.debug("Attempt to acquire credentials through provided password")
|
||||
user = gssapi.Name(upn, gssapi.NameType.user)
|
||||
password = self._password.encode("utf-8")
|
||||
|
||||
try:
|
||||
acquire_cred_result = gssapi.raw.acquire_cred_with_password(
|
||||
user, password, usage="initiate"
|
||||
)
|
||||
creds = acquire_cred_result.creds
|
||||
gssapi.raw.store_cred_into(
|
||||
self.get_store(),
|
||||
creds=creds,
|
||||
mech=gssapi.MechType.kerberos,
|
||||
overwrite=True,
|
||||
set_default=True,
|
||||
)
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
raise ProgrammingError(
|
||||
f"Unable to acquire credentials with the given password: {err}"
|
||||
) from err
|
||||
return creds
|
||||
|
||||
@staticmethod
|
||||
def _parse_auth_data(packet: bytes) -> Tuple[str, str]:
|
||||
"""Parse authentication data.
|
||||
|
||||
Get the SPN and REALM from the authentication data packet.
|
||||
|
||||
Format:
|
||||
SPN string length two bytes <B1> <B2> +
|
||||
SPN string +
|
||||
UPN realm string length two bytes <B1> <B2> +
|
||||
UPN realm string
|
||||
|
||||
Returns:
|
||||
tuple: With 'spn' and 'realm'.
|
||||
"""
|
||||
spn_len = struct.unpack("<H", packet[:2])[0]
|
||||
packet = packet[2:]
|
||||
|
||||
spn = struct.unpack(f"<{spn_len}s", packet[:spn_len])[0]
|
||||
packet = packet[spn_len:]
|
||||
|
||||
realm_len = struct.unpack("<H", packet[:2])[0]
|
||||
realm = struct.unpack(f"<{realm_len}s", packet[2:])[0]
|
||||
|
||||
return spn.decode(), realm.decode()
|
||||
|
||||
def auth_response(
|
||||
self, auth_data: Optional[bytes] = None, **kwargs: Any
|
||||
) -> Optional[bytes]:
|
||||
"""Prepare the first message to the server."""
|
||||
spn = None
|
||||
realm = None
|
||||
|
||||
if auth_data and not kwargs.get("ignore_auth_data", True):
|
||||
try:
|
||||
spn, realm = self._parse_auth_data(auth_data)
|
||||
except struct.error as err:
|
||||
raise InterruptedError(f"Invalid authentication data: {err}") from err
|
||||
|
||||
if spn is None:
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
upn = f"{self._username}@{realm}" if self._username else None
|
||||
|
||||
logger.debug("Service Principal: %s", spn)
|
||||
logger.debug("Realm: %s", realm)
|
||||
|
||||
try:
|
||||
# Attempt to retrieve credentials from cache file
|
||||
creds: Any = gssapi.Credentials(usage="initiate")
|
||||
creds_upn = str(creds.name)
|
||||
|
||||
logger.debug("Cached credentials found")
|
||||
logger.debug("Cached credentials UPN: %s", creds_upn)
|
||||
|
||||
# Remove the realm from user
|
||||
if creds_upn.find("@") != -1:
|
||||
creds_user, creds_realm = creds_upn.split("@", 1)
|
||||
else:
|
||||
creds_user = creds_upn
|
||||
creds_realm = None
|
||||
|
||||
upn = f"{self._username}@{realm}" if self._username else creds_upn
|
||||
|
||||
# The user from cached credentials matches with the given user?
|
||||
if self._username and self._username != creds_user:
|
||||
logger.debug(
|
||||
"The user from cached credentials doesn't match with the "
|
||||
"given user"
|
||||
)
|
||||
if self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
if creds_realm and creds_realm != realm and self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
except gssapi.raw.exceptions.ExpiredCredentialsError as err:
|
||||
if upn and self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
else:
|
||||
raise InterfaceError(f"Credentials has expired: {err}") from err
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
if upn and self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
else:
|
||||
raise InterfaceError(
|
||||
f"Unable to retrieve cached credentials error: {err}"
|
||||
) from err
|
||||
|
||||
flags = (
|
||||
gssapi.RequirementFlag.mutual_authentication,
|
||||
gssapi.RequirementFlag.extended_error,
|
||||
gssapi.RequirementFlag.delegate_to_peer,
|
||||
)
|
||||
name = gssapi.Name(spn, name_type=gssapi.NameType.kerberos_principal)
|
||||
cname = name.canonicalize(gssapi.MechType.kerberos)
|
||||
self.context = gssapi.SecurityContext(
|
||||
name=cname, creds=creds, flags=sum(flags), usage="initiate"
|
||||
)
|
||||
|
||||
try:
|
||||
initial_client_token: Optional[bytes] = self.context.step()
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
raise InterfaceError(f"Unable to initiate security context: {err}") from err
|
||||
|
||||
logger.debug("Initial client token: %s", initial_client_token)
|
||||
return initial_client_token
|
||||
|
||||
def auth_continue(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge: the challenge for the negotiation.
|
||||
|
||||
Returns:
|
||||
tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
logger.debug("tgt_auth challenge: %s", tgt_auth_challenge)
|
||||
|
||||
resp: Optional[bytes] = self.context.step(tgt_auth_challenge)
|
||||
|
||||
logger.debug("Context step response: %s", resp)
|
||||
logger.debug("Context completed?: %s", self.context.complete)
|
||||
|
||||
return resp, self.context.complete
|
||||
|
||||
def auth_accept_close_handshake(self, message: bytes) -> bytes:
|
||||
"""Accept handshake and generate closing handshake message for server.
|
||||
|
||||
This method verifies the server authenticity from the given message
|
||||
and included signature and generates the closing handshake for the
|
||||
server.
|
||||
|
||||
When this method is invoked the security context is already established
|
||||
and the client and server can send GSSAPI formated secure messages.
|
||||
|
||||
To finish the authentication handshake the server sends a message
|
||||
with the security layer availability and the maximum buffer size.
|
||||
|
||||
Since the connector only uses the GSSAPI authentication mechanism to
|
||||
authenticate the user with the server, the server will verify clients
|
||||
message signature and terminate the GSSAPI authentication and send two
|
||||
messages; an authentication acceptance b'\x01\x00\x00\x08\x01' and a
|
||||
OK packet (that must be received after sent the returned message from
|
||||
this method).
|
||||
|
||||
Args:
|
||||
message: a wrapped gssapi message from the server.
|
||||
|
||||
Returns:
|
||||
bytearray (closing handshake message to be send to the server).
|
||||
"""
|
||||
if not self.context.complete:
|
||||
raise ProgrammingError("Security context is not completed")
|
||||
logger.debug("Server message: %s", message)
|
||||
logger.debug("GSSAPI flags in use: %s", self.context.actual_flags)
|
||||
try:
|
||||
unwraped = self.context.unwrap(message)
|
||||
logger.debug("Unwraped: %s", unwraped)
|
||||
except gssapi.raw.exceptions.BadMICError as err:
|
||||
logger.debug("Unable to unwrap server message: %s", err)
|
||||
raise InterfaceError(f"Unable to unwrap server message: {err}") from err
|
||||
|
||||
logger.debug("Unwrapped server message: %s", unwraped)
|
||||
# The message contents for the clients closing message:
|
||||
# - security level 1 byte, must be always 1.
|
||||
# - conciliated buffer size 3 bytes, without importance as no
|
||||
# further GSSAPI messages will be sends.
|
||||
response = bytearray(b"\x01\x00\x00\00")
|
||||
# Closing handshake must not be encrypted.
|
||||
logger.debug("Message response: %s", response)
|
||||
wraped = self.context.wrap(response, encrypt=False)
|
||||
logger.debug(
|
||||
"Wrapped message response: %s, length: %d",
|
||||
wraped[0],
|
||||
len(wraped[0]),
|
||||
)
|
||||
|
||||
return wraped.message
|
||||
|
||||
|
||||
class MySQLSSPIKerberosAuthPlugin(MySQLBaseKerberosAuthPlugin):
|
||||
"""Implement the MySQL Kerberos authentication plugin with Windows SSPI"""
|
||||
|
||||
context: Any = None
|
||||
clientauth: Any = None
|
||||
|
||||
@staticmethod
|
||||
def _parse_auth_data(packet: bytes) -> Tuple[str, str]:
|
||||
"""Parse authentication data.
|
||||
|
||||
Get the SPN and REALM from the authentication data packet.
|
||||
|
||||
Format:
|
||||
SPN string length two bytes <B1> <B2> +
|
||||
SPN string +
|
||||
UPN realm string length two bytes <B1> <B2> +
|
||||
UPN realm string
|
||||
|
||||
Returns:
|
||||
tuple: With 'spn' and 'realm'.
|
||||
"""
|
||||
spn_len = struct.unpack("<H", packet[:2])[0]
|
||||
packet = packet[2:]
|
||||
|
||||
spn = struct.unpack(f"<{spn_len}s", packet[:spn_len])[0]
|
||||
packet = packet[spn_len:]
|
||||
|
||||
realm_len = struct.unpack("<H", packet[:2])[0]
|
||||
realm = struct.unpack(f"<{realm_len}s", packet[2:])[0]
|
||||
|
||||
return spn.decode(), realm.decode()
|
||||
|
||||
def auth_response(
|
||||
self, auth_data: Optional[bytes] = None, **kwargs: Any
|
||||
) -> Optional[bytes]:
|
||||
"""Prepare the first message to the server.
|
||||
|
||||
Args:
|
||||
kwargs:
|
||||
ignore_auth_data (bool): if True, the provided auth data is ignored.
|
||||
"""
|
||||
logger.debug("auth_response for sspi")
|
||||
spn = None
|
||||
realm = None
|
||||
|
||||
if auth_data and not kwargs.get("ignore_auth_data", True):
|
||||
try:
|
||||
spn, realm = self._parse_auth_data(auth_data)
|
||||
except struct.error as err:
|
||||
raise InterruptedError(f"Invalid authentication data: {err}") from err
|
||||
|
||||
logger.debug("Service Principal: %s", spn)
|
||||
logger.debug("Realm: %s", realm)
|
||||
|
||||
if sspicon is None or sspi is None:
|
||||
raise ProgrammingError(
|
||||
'Package "pywin32" (Python for Win32 (pywin32) extensions)'
|
||||
" is not installed."
|
||||
)
|
||||
|
||||
flags = (sspicon.ISC_REQ_MUTUAL_AUTH, sspicon.ISC_REQ_DELEGATE)
|
||||
|
||||
if self._username and self._password:
|
||||
_auth_info = (self._username, realm, self._password)
|
||||
else:
|
||||
_auth_info = None
|
||||
|
||||
targetspn = spn
|
||||
logger.debug("targetspn: %s", targetspn)
|
||||
logger.debug("_auth_info is None: %s", _auth_info is None)
|
||||
|
||||
# The Security Support Provider Interface (SSPI) is an interface
|
||||
# that allows us to choose from a set of SSPs available in the
|
||||
# system; the idea of SSPI is to keep interface consistent no
|
||||
# matter what back end (a.k.a., SSP) we choose.
|
||||
|
||||
# When using SSPI we should not use Kerberos directly as SSP,
|
||||
# as remarked in [2], but we can use it indirectly via another
|
||||
# SSP named Negotiate that acts as an application layer between
|
||||
# SSPI and the other SSPs [1].
|
||||
|
||||
# Negotiate can select between Kerberos and NTLM on the fly;
|
||||
# it chooses Kerberos unless it cannot be used by one of the
|
||||
# systems involved in the authentication or the calling
|
||||
# application did not provide sufficient information to use
|
||||
# Kerberos.
|
||||
|
||||
# prefix: https://docs.microsoft.com/en-us/windows/win32/secauthn
|
||||
# [1] prefix/microsoft-negotiate?source=recommendations
|
||||
# [2] prefix/microsoft-kerberos?source=recommendations
|
||||
self.clientauth = sspi.ClientAuth(
|
||||
"Negotiate",
|
||||
targetspn=targetspn,
|
||||
auth_info=_auth_info,
|
||||
scflags=sum(flags),
|
||||
datarep=sspicon.SECURITY_NETWORK_DREP,
|
||||
)
|
||||
|
||||
try:
|
||||
data = None
|
||||
err, out_buf = self.clientauth.authorize(data)
|
||||
logger.debug("Context step err: %s", err)
|
||||
logger.debug("Context step out_buf: %s", out_buf)
|
||||
logger.debug("Context completed?: %s", self.clientauth.authenticated)
|
||||
initial_client_token = out_buf[0].Buffer
|
||||
logger.debug("pkg_info: %s", self.clientauth.pkg_info)
|
||||
except Exception as err:
|
||||
raise InterfaceError(f"Unable to initiate security context: {err}") from err
|
||||
|
||||
logger.debug("Initial client token: %s", initial_client_token)
|
||||
return initial_client_token
|
||||
|
||||
def auth_continue(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge: the challenge for the negotiation.
|
||||
|
||||
Returns:
|
||||
tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
logger.debug("tgt_auth challenge: %s", tgt_auth_challenge)
|
||||
|
||||
err, out_buf = self.clientauth.authorize(tgt_auth_challenge)
|
||||
|
||||
logger.debug("Context step err: %s", err)
|
||||
logger.debug("Context step out_buf: %s", out_buf)
|
||||
resp = out_buf[0].Buffer
|
||||
logger.debug("Context step resp: %s", resp)
|
||||
logger.debug("Context completed?: %s", self.clientauth.authenticated)
|
||||
|
||||
return resp, self.clientauth.authenticated
|
||||
+595
@@ -0,0 +1,595 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""LDAP SASL Authentication Plugin."""
|
||||
|
||||
import hmac
|
||||
|
||||
from base64 import b64decode, b64encode
|
||||
from hashlib import sha1, sha256
|
||||
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple
|
||||
from uuid import uuid4
|
||||
|
||||
from ..authentication import ERR_STATUS
|
||||
from ..errors import InterfaceError, ProgrammingError
|
||||
from ..logger import logger
|
||||
from ..types import StrOrBytes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
import gssapi
|
||||
except ImportError:
|
||||
raise ProgrammingError(
|
||||
"Module gssapi is required for GSSAPI authentication "
|
||||
"mechanism but was not found. Unable to authenticate "
|
||||
"with the server"
|
||||
) from None
|
||||
|
||||
from ..utils import (
|
||||
normalize_unicode_string as norm_ustr,
|
||||
validate_normalized_unicode_string as valid_norm,
|
||||
)
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLLdapSaslPasswordAuthPlugin"
|
||||
|
||||
|
||||
# pylint: disable=c-extension-no-member,no-member
|
||||
class MySQLLdapSaslPasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL ldap sasl authentication plugin.
|
||||
|
||||
The MySQL's ldap sasl authentication plugin support two authentication
|
||||
methods SCRAM-SHA-1 and GSSAPI (using Kerberos). This implementation only
|
||||
support SCRAM-SHA-1 and SCRAM-SHA-256.
|
||||
|
||||
SCRAM-SHA-1 amd SCRAM-SHA-256
|
||||
This method requires 2 messages from client and 2 responses from
|
||||
server.
|
||||
|
||||
The first message from client will be generated by prepare_password(),
|
||||
after receive the response from the server, it is required that this
|
||||
response is passed back to auth_continue() which will return the
|
||||
second message from the client. After send this second message to the
|
||||
server, the second server respond needs to be passed to auth_finalize()
|
||||
to finish the authentication process.
|
||||
"""
|
||||
|
||||
sasl_mechanisms: List[str] = ["SCRAM-SHA-1", "SCRAM-SHA-256", "GSSAPI"]
|
||||
def_digest_mode: Callable = sha1
|
||||
client_nonce: Optional[str] = None
|
||||
client_salt: Any = None
|
||||
server_salt: Optional[str] = None
|
||||
krb_service_principal: Optional[str] = None
|
||||
iterations: int = 0
|
||||
server_auth_var: Optional[str] = None
|
||||
target_name: Optional[gssapi.Name] = None
|
||||
ctx: gssapi.SecurityContext = None
|
||||
servers_first: Optional[str] = None
|
||||
server_nonce: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def _xor(bytes1: bytes, bytes2: bytes) -> bytes:
|
||||
return bytes([b1 ^ b2 for b1, b2 in zip(bytes1, bytes2)])
|
||||
|
||||
def _hmac(self, password: bytes, salt: bytes) -> bytes:
|
||||
digest_maker = hmac.new(password, salt, self.def_digest_mode)
|
||||
return digest_maker.digest()
|
||||
|
||||
def _hi(self, password: str, salt: bytes, count: int) -> bytes:
|
||||
"""Prepares Hi
|
||||
Hi(password, salt, iterations) where Hi(p,s,i) is defined as
|
||||
PBKDF2 (HMAC, p, s, i, output length of H).
|
||||
"""
|
||||
pw = password.encode()
|
||||
hi = self._hmac(pw, salt + b"\x00\x00\x00\x01")
|
||||
aux = hi
|
||||
for _ in range(count - 1):
|
||||
aux = self._hmac(pw, aux)
|
||||
hi = self._xor(hi, aux)
|
||||
return hi
|
||||
|
||||
@staticmethod
|
||||
def _normalize(string: str) -> str:
|
||||
norm_str = norm_ustr(string)
|
||||
broken_rule = valid_norm(norm_str)
|
||||
if broken_rule is not None:
|
||||
raise InterfaceError(f"broken_rule: {broken_rule}")
|
||||
return norm_str
|
||||
|
||||
def _first_message(self) -> bytes:
|
||||
"""This method generates the first message to the server to start the
|
||||
|
||||
The client-first message consists of a gs2-header,
|
||||
the desired username, and a randomly generated client nonce cnonce.
|
||||
|
||||
The first message from the server has the form:
|
||||
b'n,a=<user_name>,n=<user_name>,r=<client_nonce>
|
||||
|
||||
Returns client's first message
|
||||
"""
|
||||
cfm_fprnat = "n,a={user_name},n={user_name},r={client_nonce}"
|
||||
self.client_nonce = str(uuid4()).replace("-", "")
|
||||
cfm: StrOrBytes = cfm_fprnat.format(
|
||||
user_name=self._normalize(self._username),
|
||||
client_nonce=self.client_nonce,
|
||||
)
|
||||
|
||||
if isinstance(cfm, str):
|
||||
cfm = cfm.encode("utf8")
|
||||
return cfm
|
||||
|
||||
def _first_message_krb(self) -> Optional[bytes]:
|
||||
"""Get a TGT Authentication request and initiates security context.
|
||||
|
||||
This method will contact the Kerberos KDC in order of obtain a TGT.
|
||||
"""
|
||||
user_name = gssapi.raw.names.import_name(
|
||||
self._username.encode("utf8"), name_type=gssapi.NameType.user
|
||||
)
|
||||
|
||||
# Use defaults store = {'ccache': 'FILE:/tmp/krb5cc_1000'}#,
|
||||
# 'keytab':'/etc/some.keytab' }
|
||||
# Attempt to retrieve credential from default cache file.
|
||||
try:
|
||||
cred: Any = gssapi.Credentials()
|
||||
logger.debug(
|
||||
"# Stored credentials found, if password was given it will be ignored."
|
||||
)
|
||||
try:
|
||||
# validate credentials has not expired.
|
||||
cred.lifetime
|
||||
except gssapi.raw.exceptions.ExpiredCredentialsError as err:
|
||||
logger.warning(" Credentials has expired: %s", err)
|
||||
cred.acquire(user_name)
|
||||
raise InterfaceError(f"Credentials has expired: {err}") from err
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
if not self._password:
|
||||
raise InterfaceError(
|
||||
f"Unable to retrieve stored credentials error: {err}"
|
||||
) from err
|
||||
try:
|
||||
logger.debug("# Attempt to retrieve credentials with given password")
|
||||
acquire_cred_result = gssapi.raw.acquire_cred_with_password(
|
||||
user_name,
|
||||
self._password.encode("utf8"),
|
||||
usage="initiate",
|
||||
)
|
||||
cred = acquire_cred_result[0]
|
||||
except gssapi.raw.misc.GSSError as err2:
|
||||
raise ProgrammingError(
|
||||
f"Unable to retrieve credentials with the given password: {err2}"
|
||||
) from err
|
||||
|
||||
flags_l = (
|
||||
gssapi.RequirementFlag.mutual_authentication,
|
||||
gssapi.RequirementFlag.extended_error,
|
||||
gssapi.RequirementFlag.delegate_to_peer,
|
||||
)
|
||||
|
||||
if self.krb_service_principal:
|
||||
service_principal = self.krb_service_principal
|
||||
else:
|
||||
service_principal = "ldap/ldapauth"
|
||||
logger.debug("# service principal: %s", service_principal)
|
||||
servk = gssapi.Name(
|
||||
service_principal, name_type=gssapi.NameType.kerberos_principal
|
||||
)
|
||||
self.target_name = servk
|
||||
self.ctx = gssapi.SecurityContext(
|
||||
name=servk, creds=cred, flags=sum(flags_l), usage="initiate"
|
||||
)
|
||||
|
||||
try:
|
||||
# step() returns bytes | None, see documentation,
|
||||
# so this method could return a NULL payload.
|
||||
# ref: https://pythongssapi.github.io/<suffix>
|
||||
# suffix: python-gssapi/latest/gssapi.html#gssapi.sec_contexts.SecurityContext
|
||||
initial_client_token = self.ctx.step()
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
raise InterfaceError(f"Unable to initiate security context: {err}") from err
|
||||
|
||||
logger.debug("# initial client token: %s", initial_client_token)
|
||||
return initial_client_token
|
||||
|
||||
def auth_continue_krb(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge the challenge for the negotiation.
|
||||
|
||||
Returns: tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
logger.debug("tgt_auth challenge: %s", tgt_auth_challenge)
|
||||
|
||||
resp = self.ctx.step(tgt_auth_challenge)
|
||||
logger.debug("# context step response: %s", resp)
|
||||
logger.debug("# context completed?: %s", self.ctx.complete)
|
||||
|
||||
return resp, self.ctx.complete
|
||||
|
||||
def auth_accept_close_handshake(self, message: bytes) -> bytes:
|
||||
"""Accept handshake and generate closing handshake message for server.
|
||||
|
||||
This method verifies the server authenticity from the given message
|
||||
and included signature and generates the closing handshake for the
|
||||
server.
|
||||
|
||||
When this method is invoked the security context is already established
|
||||
and the client and server can send GSSAPI formated secure messages.
|
||||
|
||||
To finish the authentication handshake the server sends a message
|
||||
with the security layer availability and the maximum buffer size.
|
||||
|
||||
Since the connector only uses the GSSAPI authentication mechanism to
|
||||
authenticate the user with the server, the server will verify clients
|
||||
message signature and terminate the GSSAPI authentication and send two
|
||||
messages; an authentication acceptance b'\x01\x00\x00\x08\x01' and a
|
||||
OK packet (that must be received after sent the returned message from
|
||||
this method).
|
||||
|
||||
Args:
|
||||
message a wrapped hssapi message from the server.
|
||||
|
||||
Returns: bytearray closing handshake message to be send to the server.
|
||||
"""
|
||||
if not self.ctx.complete:
|
||||
raise ProgrammingError("Security context is not completed.")
|
||||
logger.debug("# servers message: %s", message)
|
||||
logger.debug("# GSSAPI flags in use: %s", self.ctx.actual_flags)
|
||||
try:
|
||||
unwraped = self.ctx.unwrap(message)
|
||||
logger.debug("# unwraped: %s", unwraped)
|
||||
except gssapi.raw.exceptions.BadMICError as err:
|
||||
raise InterfaceError(f"Unable to unwrap server message: {err}") from err
|
||||
|
||||
logger.debug("# unwrapped server message: %s", unwraped)
|
||||
# The message contents for the clients closing message:
|
||||
# - security level 1 byte, must be always 1.
|
||||
# - conciliated buffer size 3 bytes, without importance as no
|
||||
# further GSSAPI messages will be sends.
|
||||
response = bytearray(b"\x01\x00\x00\00")
|
||||
# Closing handshake must not be encrypted.
|
||||
logger.debug("# message response: %s", response)
|
||||
wraped = self.ctx.wrap(response, encrypt=False)
|
||||
logger.debug(
|
||||
"# wrapped message response: %s, length: %d",
|
||||
wraped[0],
|
||||
len(wraped[0]),
|
||||
)
|
||||
|
||||
return wraped.message
|
||||
|
||||
def auth_response(
|
||||
self,
|
||||
auth_data: bytes,
|
||||
**kwargs: Any,
|
||||
) -> Optional[bytes]:
|
||||
"""This method will prepare the fist message to the server.
|
||||
|
||||
Returns bytes to send to the server as the first message.
|
||||
"""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self._auth_data = auth_data
|
||||
|
||||
auth_mechanism = self._auth_data.decode()
|
||||
logger.debug("read_method_name_from_server: %s", auth_mechanism)
|
||||
if auth_mechanism not in self.sasl_mechanisms:
|
||||
auth_mechanisms = '", "'.join(self.sasl_mechanisms[:-1])
|
||||
raise InterfaceError(
|
||||
f'The sasl authentication method "{auth_mechanism}" requested '
|
||||
f'from the server is not supported. Only "{auth_mechanisms}" '
|
||||
f'and "{self.sasl_mechanisms[-1]}" are supported'
|
||||
)
|
||||
|
||||
if b"GSSAPI" in self._auth_data:
|
||||
return self._first_message_krb()
|
||||
|
||||
if self._auth_data == b"SCRAM-SHA-256":
|
||||
self.def_digest_mode = sha256
|
||||
|
||||
return self._first_message()
|
||||
|
||||
def _second_message(self) -> bytes:
|
||||
"""This method generates the second message to the server
|
||||
|
||||
Second message consist on the concatenation of the client and the
|
||||
server nonce, and cproof.
|
||||
|
||||
c=<n,a=<user_name>>,r=<server_nonce>,p=<client_proof>
|
||||
where:
|
||||
<client_proof>: xor(<client_key>, <client_signature>)
|
||||
|
||||
<client_key>: hmac(salted_password, b"Client Key")
|
||||
<client_signature>: hmac(<stored_key>, <auth_msg>)
|
||||
<stored_key>: h(<client_key>)
|
||||
<auth_msg>: <client_first_no_header>,<servers_first>,
|
||||
c=<client_header>,r=<server_nonce>
|
||||
<client_first_no_header>: n=<username>r=<client_nonce>
|
||||
"""
|
||||
if not self._auth_data:
|
||||
raise InterfaceError("Missing authentication data (seed)")
|
||||
|
||||
passw = self._normalize(self._password)
|
||||
salted_password = self._hi(passw, b64decode(self.server_salt), self.iterations)
|
||||
logger.debug("salted_password: %s", b64encode(salted_password).decode())
|
||||
|
||||
client_key = self._hmac(salted_password, b"Client Key")
|
||||
logger.debug("client_key: %s", b64encode(client_key).decode())
|
||||
|
||||
stored_key = self.def_digest_mode(client_key).digest()
|
||||
logger.debug("stored_key: %s", b64encode(stored_key).decode())
|
||||
|
||||
server_key = self._hmac(salted_password, b"Server Key")
|
||||
logger.debug("server_key: %s", b64encode(server_key).decode())
|
||||
|
||||
client_first_no_header = ",".join(
|
||||
[
|
||||
f"n={self._normalize(self._username)}",
|
||||
f"r={self.client_nonce}",
|
||||
]
|
||||
)
|
||||
logger.debug("client_first_no_header: %s", client_first_no_header)
|
||||
|
||||
client_header = b64encode(
|
||||
f"n,a={self._normalize(self._username)},".encode()
|
||||
).decode()
|
||||
|
||||
auth_msg = ",".join(
|
||||
[
|
||||
client_first_no_header,
|
||||
self.servers_first,
|
||||
f"c={client_header}",
|
||||
f"r={self.server_nonce}",
|
||||
]
|
||||
)
|
||||
logger.debug("auth_msg: %s", auth_msg)
|
||||
|
||||
client_signature = self._hmac(stored_key, auth_msg.encode())
|
||||
logger.debug("client_signature: %s", b64encode(client_signature).decode())
|
||||
|
||||
client_proof = self._xor(client_key, client_signature)
|
||||
logger.debug("client_proof: %s", b64encode(client_proof).decode())
|
||||
|
||||
self.server_auth_var = b64encode(
|
||||
self._hmac(server_key, auth_msg.encode())
|
||||
).decode()
|
||||
logger.debug("server_auth_var: %s", self.server_auth_var)
|
||||
|
||||
msg = ",".join(
|
||||
[
|
||||
f"c={client_header}",
|
||||
f"r={self.server_nonce}",
|
||||
f"p={b64encode(client_proof).decode()}",
|
||||
]
|
||||
)
|
||||
logger.debug("second_message: %s", msg)
|
||||
return msg.encode()
|
||||
|
||||
def _validate_first_reponse(self, servers_first: bytes) -> None:
|
||||
"""Validates first message from the server.
|
||||
|
||||
Extracts the server's salt and iterations from the servers 1st response.
|
||||
First message from the server is in the form:
|
||||
<server_salt>,i=<iterations>
|
||||
"""
|
||||
if not servers_first or not isinstance(servers_first, (bytearray, bytes)):
|
||||
raise InterfaceError(f"Unexpected server message: {repr(servers_first)}")
|
||||
try:
|
||||
servers_first_str = servers_first.decode()
|
||||
self.servers_first = servers_first_str
|
||||
r_server_nonce, s_salt, i_counter = servers_first_str.split(",")
|
||||
except ValueError:
|
||||
raise InterfaceError(
|
||||
f"Unexpected server message: {servers_first_str}"
|
||||
) from None
|
||||
if (
|
||||
not r_server_nonce.startswith("r=")
|
||||
or not s_salt.startswith("s=")
|
||||
or not i_counter.startswith("i=")
|
||||
):
|
||||
raise InterfaceError(
|
||||
f"Incomplete reponse from the server: {servers_first_str}"
|
||||
)
|
||||
if self.client_nonce in r_server_nonce:
|
||||
self.server_nonce = r_server_nonce[2:]
|
||||
logger.debug("server_nonce: %s", self.server_nonce)
|
||||
else:
|
||||
raise InterfaceError(
|
||||
"Unable to authenticate response: response not well formed "
|
||||
f"{servers_first_str}"
|
||||
)
|
||||
self.server_salt = s_salt[2:]
|
||||
logger.debug(
|
||||
"server_salt: %s length: %s",
|
||||
self.server_salt,
|
||||
len(self.server_salt),
|
||||
)
|
||||
try:
|
||||
i_counter = i_counter[2:]
|
||||
logger.debug("iterations: %s", i_counter)
|
||||
self.iterations = int(i_counter)
|
||||
except Exception as err:
|
||||
raise InterfaceError(
|
||||
f"Unable to authenticate: iterations not found {servers_first_str}"
|
||||
) from err
|
||||
|
||||
def auth_continue(self, servers_first_response: bytes) -> bytes:
|
||||
"""return the second message from the client.
|
||||
|
||||
Returns bytes to send to the server as the second message.
|
||||
"""
|
||||
self._validate_first_reponse(servers_first_response)
|
||||
return self._second_message()
|
||||
|
||||
def _validate_second_reponse(self, servers_second: bytearray) -> bool:
|
||||
"""Validates second message from the server.
|
||||
|
||||
The client and the server prove to each other they have the same Auth
|
||||
variable.
|
||||
|
||||
The second message from the server consist of the server's proof:
|
||||
server_proof = HMAC(<server_key>, <auth_msg>)
|
||||
where:
|
||||
<server_key>: hmac(<salted_password>, b"Server Key")
|
||||
<auth_msg>: <client_first_no_header>,<servers_first>,
|
||||
c=<client_header>,r=<server_nonce>
|
||||
|
||||
Our server_proof must be equal to the Auth variable send on this second
|
||||
response.
|
||||
"""
|
||||
if (
|
||||
not servers_second
|
||||
or not isinstance(servers_second, bytearray)
|
||||
or len(servers_second) <= 2
|
||||
or not servers_second.startswith(b"v=")
|
||||
):
|
||||
raise InterfaceError("The server's proof is not well formated")
|
||||
server_var = servers_second[2:].decode()
|
||||
logger.debug("server auth variable: %s", server_var)
|
||||
return self.server_auth_var == server_var
|
||||
|
||||
def auth_finalize(self, servers_second_response: bytearray) -> bool:
|
||||
"""finalize the authentication process.
|
||||
|
||||
Raises InterfaceError if the ervers_second_response is invalid.
|
||||
|
||||
Returns True in successful authentication False otherwise.
|
||||
"""
|
||||
if not self._validate_second_reponse(servers_second_response):
|
||||
raise InterfaceError(
|
||||
"Authentication failed: Unable to proof server identity"
|
||||
)
|
||||
return True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_ldap_sasl_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
logger.debug("# auth_data: %s", auth_data)
|
||||
self.krb_service_principal = kwargs.get("krb_service_principal")
|
||||
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
packet = sock.recv()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
if len(packet) >= 6 and packet[5] == 114 and packet[6] == 61: # 'r' and '='
|
||||
# Continue with sasl authentication
|
||||
dec_response = packet[5:]
|
||||
cresponse = self.auth_continue(dec_response)
|
||||
sock.send(cresponse)
|
||||
packet = sock.recv()
|
||||
if packet[5] == 118 and packet[6] == 61: # 'v' and '='
|
||||
if self.auth_finalize(packet[5:]):
|
||||
# receive packed OK
|
||||
packet = sock.recv()
|
||||
elif auth_data == b"GSSAPI" and packet[4] != ERR_STATUS:
|
||||
rcode_size = 5 # header size for the response status code.
|
||||
logger.debug("# Continue with sasl GSSAPI authentication")
|
||||
logger.debug("# response header: %s", packet[: rcode_size + 1])
|
||||
logger.debug("# response size: %s", len(packet))
|
||||
|
||||
logger.debug("# Negotiate a service request")
|
||||
complete = False
|
||||
tries = 0 # To avoid a infinite loop attempt no more than feedback messages
|
||||
while not complete and tries < 5:
|
||||
logger.debug("%s Attempt %s %s", "-" * 20, tries + 1, "-" * 20)
|
||||
logger.debug("<< server response: %s", packet)
|
||||
logger.debug("# response code: %s", packet[: rcode_size + 1])
|
||||
step, complete = self.auth_continue_krb(packet[rcode_size:])
|
||||
logger.debug(" >> response to server: %s", step)
|
||||
sock.send(step or b"")
|
||||
packet = sock.recv()
|
||||
tries += 1
|
||||
if not complete:
|
||||
raise InterfaceError(
|
||||
f"Unable to fulfill server request after {tries} "
|
||||
f"attempts. Last server response: {packet}"
|
||||
)
|
||||
logger.debug(
|
||||
" last GSSAPI response from server: %s length: %d",
|
||||
packet,
|
||||
len(packet),
|
||||
)
|
||||
last_step = self.auth_accept_close_handshake(packet[rcode_size:])
|
||||
logger.debug(
|
||||
" >> last response to server: %s length: %d",
|
||||
last_step,
|
||||
len(last_step),
|
||||
)
|
||||
sock.send(last_step)
|
||||
# Receive final handshake from server
|
||||
packet = sock.recv()
|
||||
logger.debug("<< final handshake from server: %s", packet)
|
||||
|
||||
# receive OK packet from server.
|
||||
packet = sock.recv()
|
||||
logger.debug("<< ok packet from server: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
|
||||
|
||||
# pylint: enable=c-extension-no-member,no-member
|
||||
@@ -0,0 +1,234 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="arg-type,union-attr,call-arg"
|
||||
|
||||
"""OCI Authentication Plugin."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from .. import errors
|
||||
from ..logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
from cryptography.exceptions import UnsupportedAlgorithm
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.asymmetric.types import PRIVATE_KEY_TYPES
|
||||
except ImportError:
|
||||
raise errors.ProgrammingError("Package 'cryptography' is not installed") from None
|
||||
|
||||
try:
|
||||
from oci import config, exceptions
|
||||
except ImportError:
|
||||
raise errors.ProgrammingError(
|
||||
"Package 'oci' (Oracle Cloud Infrastructure Python SDK) is not installed"
|
||||
) from None
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLOCIAuthPlugin"
|
||||
OCI_SECURITY_TOKEN_MAX_SIZE = 10 * 1024 # In bytes
|
||||
OCI_SECURITY_TOKEN_TOO_LARGE = "Ephemeral security token is too large (10KB max)"
|
||||
OCI_SECURITY_TOKEN_FILE_NOT_AVAILABLE = (
|
||||
"Ephemeral security token file ('security_token_file') could not be read"
|
||||
)
|
||||
OCI_PROFILE_MISSING_PROPERTIES = (
|
||||
"OCI configuration file does not contain a 'fingerprint' or 'key_file' entry"
|
||||
)
|
||||
|
||||
|
||||
class MySQLOCIAuthPlugin(MySQLAuthPlugin):
|
||||
"""Implement the MySQL OCI IAM authentication plugin."""
|
||||
|
||||
context: Any = None
|
||||
oci_config_profile: str = "DEFAULT"
|
||||
oci_config_file: str = config.DEFAULT_LOCATION
|
||||
|
||||
@staticmethod
|
||||
def _prepare_auth_response(signature: bytes, oci_config: Dict[str, Any]) -> str:
|
||||
"""Prepare client's authentication response
|
||||
|
||||
Prepares client's authentication response in JSON format
|
||||
Args:
|
||||
signature (bytes): server's nonce to be signed by client.
|
||||
oci_config (dict): OCI configuration object.
|
||||
|
||||
Returns:
|
||||
str: JSON string with the following format:
|
||||
{"fingerprint": str, "signature": str, "token": base64.base64.base64}
|
||||
|
||||
Raises:
|
||||
ProgrammingError: If the ephemeral security token file can't be open or the
|
||||
token is too large.
|
||||
"""
|
||||
signature_64 = b64encode(signature)
|
||||
auth_response = {
|
||||
"fingerprint": oci_config["fingerprint"],
|
||||
"signature": signature_64.decode(),
|
||||
}
|
||||
|
||||
# The security token, if it exists, should be a JWT (JSON Web Token), consisted
|
||||
# of a base64-encoded header, body, and signature, separated by '.',
|
||||
# e.g. "Base64.Base64.Base64", stored in a file at the path specified by the
|
||||
# security_token_file configuration property
|
||||
if oci_config.get("security_token_file"):
|
||||
try:
|
||||
security_token_file = Path(oci_config["security_token_file"])
|
||||
# Check if token exceeds the maximum size
|
||||
if security_token_file.stat().st_size > OCI_SECURITY_TOKEN_MAX_SIZE:
|
||||
raise errors.ProgrammingError(OCI_SECURITY_TOKEN_TOO_LARGE)
|
||||
auth_response["token"] = security_token_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as err:
|
||||
raise errors.ProgrammingError(
|
||||
OCI_SECURITY_TOKEN_FILE_NOT_AVAILABLE
|
||||
) from err
|
||||
return json.dumps(auth_response, separators=(",", ":"))
|
||||
|
||||
@staticmethod
|
||||
def _get_private_key(key_path: str) -> PRIVATE_KEY_TYPES:
|
||||
"""Get the private_key form the given location"""
|
||||
try:
|
||||
with open(os.path.expanduser(key_path), "rb") as key_file:
|
||||
private_key = serialization.load_pem_private_key(
|
||||
key_file.read(),
|
||||
password=None,
|
||||
)
|
||||
except (TypeError, OSError, ValueError, UnsupportedAlgorithm) as err:
|
||||
raise errors.ProgrammingError(
|
||||
"An error occurred while reading the API_KEY from "
|
||||
f'"{key_path}": {err}'
|
||||
)
|
||||
|
||||
return private_key
|
||||
|
||||
def _get_valid_oci_config(self) -> Dict[str, Any]:
|
||||
"""Get a valid OCI config from the given configuration file path"""
|
||||
error_list = []
|
||||
req_keys = {
|
||||
"fingerprint": (lambda x: len(x) > 32),
|
||||
"key_file": (lambda x: os.path.exists(os.path.expanduser(x))),
|
||||
}
|
||||
|
||||
oci_config: Dict[str, Any] = {}
|
||||
try:
|
||||
# key_file is validated by oci.config if present
|
||||
oci_config = config.from_file(
|
||||
self.oci_config_file or config.DEFAULT_LOCATION,
|
||||
self.oci_config_profile or "DEFAULT",
|
||||
)
|
||||
for req_key, req_value in req_keys.items():
|
||||
try:
|
||||
# Verify parameter in req_key is present and valid
|
||||
if oci_config[req_key] and not req_value(oci_config[req_key]):
|
||||
error_list.append(f'Parameter "{req_key}" is invalid')
|
||||
except KeyError:
|
||||
error_list.append(f"Does not contain parameter {req_key}")
|
||||
except (
|
||||
exceptions.ConfigFileNotFound,
|
||||
exceptions.InvalidConfig,
|
||||
exceptions.InvalidKeyFilePath,
|
||||
exceptions.InvalidPrivateKey,
|
||||
exceptions.ProfileNotFound,
|
||||
) as err:
|
||||
error_list.append(str(err))
|
||||
|
||||
# Raise errors if any
|
||||
if error_list:
|
||||
raise errors.ProgrammingError(
|
||||
f"Invalid oci-config-file: {self.oci_config_file}. "
|
||||
f"Errors found: {error_list}"
|
||||
)
|
||||
|
||||
return oci_config
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_oci_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Prepare authentication string for the server."""
|
||||
logger.debug("server nonce: %s, len %d", auth_data, len(auth_data))
|
||||
|
||||
oci_config = self._get_valid_oci_config()
|
||||
|
||||
private_key = self._get_private_key(oci_config["key_file"])
|
||||
signature = private_key.sign(auth_data, padding.PKCS1v15(), hashes.SHA256())
|
||||
|
||||
auth_response = self._prepare_auth_response(signature, oci_config)
|
||||
logger.debug("authentication response: %s", auth_response)
|
||||
return auth_response.encode()
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
self.oci_config_file = kwargs.get("oci_config_file", "DEFAULT")
|
||||
self.oci_config_profile = kwargs.get(
|
||||
"oci_config_profile", config.DEFAULT_LOCATION
|
||||
)
|
||||
logger.debug("# oci configuration file path: %s", self.oci_config_file)
|
||||
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
packet = sock.recv()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
# Copyright (c) 2024, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""OpenID Authentication Plugin."""
|
||||
|
||||
import re
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
from mysql.connector import utils
|
||||
|
||||
from .. import errors
|
||||
from ..logger import logger
|
||||
from ..network import MySQLSocket
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLOpenIDConnectAuthPlugin"
|
||||
OPENID_TOKEN_MAX_SIZE = 10 * 1024 # In bytes
|
||||
|
||||
|
||||
class MySQLOpenIDConnectAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL OpenID Connect Authentication Plugin."""
|
||||
|
||||
_openid_capability_flag: bytes = utils.int1store(1)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_openid_connect_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _validate_openid_token(token: str) -> bool:
|
||||
"""Helper method used to validate OpenID Connect token
|
||||
|
||||
The Token is represented as a JSON Web Token (JWT) consists of a
|
||||
base64-encoded header, body, and signature, separated by '.' e.g.,
|
||||
"Base64url.Base64url.Base64url". The First part of the token contains
|
||||
the header, the second part contains payload and the third part contains
|
||||
signature. These token parts should be Base64 URLSafe i.e., Token cannot
|
||||
contain characters other than a-z, A-Z, 0-9 and special characters '-', '_'.
|
||||
|
||||
Args:
|
||||
token (str): Base64url-encoded OpenID connect token fetched from
|
||||
the file path passed via `openid_token_file` connection
|
||||
argument.
|
||||
|
||||
Returns:
|
||||
bool: Signal indicating whether the token is valid or not.
|
||||
"""
|
||||
header_payload_sig: List[str] = token.split(".")
|
||||
if len(header_payload_sig) != 3:
|
||||
# invalid structure
|
||||
return False
|
||||
urlsafe_pattern = re.compile("^[a-zA-Z0-9-_]*$")
|
||||
return all(
|
||||
(
|
||||
len(token_part) and urlsafe_pattern.search(token_part) is not None
|
||||
for token_part in header_payload_sig
|
||||
)
|
||||
)
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> bytes:
|
||||
"""Prepares authentication string for the server.
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
The OpenID Connect authorization response follows the pattern :-
|
||||
int<1> capability flag
|
||||
string<lenenc> id token
|
||||
|
||||
Raises:
|
||||
InterfaceError: If the connection is insecure or the OpenID Token is too large,
|
||||
invalid or non-existent.
|
||||
ProgrammingError: If the OpenID Token file could not be read.
|
||||
"""
|
||||
try:
|
||||
# Check if the connection is secure
|
||||
if self.requires_ssl and not self._ssl_enabled:
|
||||
raise errors.InterfaceError(f"{self.name} requires SSL")
|
||||
|
||||
# Validate the file
|
||||
token_file_path: str = kwargs.get("openid_token_file", None)
|
||||
openid_token_file: Path = Path(token_file_path)
|
||||
# Check if token exceeds the maximum size
|
||||
if openid_token_file.stat().st_size > OPENID_TOKEN_MAX_SIZE:
|
||||
raise errors.InterfaceError(
|
||||
"The OpenID Connect token file size is too large (> 10KB)"
|
||||
)
|
||||
openid_token: str = openid_token_file.read_text(encoding="utf-8")
|
||||
openid_token = openid_token.strip()
|
||||
# Validate the JWT Token
|
||||
if not self._validate_openid_token(openid_token):
|
||||
raise errors.InterfaceError("The OpenID Connect Token is invalid")
|
||||
|
||||
# build the auth_response packet
|
||||
auth_response: List[bytes] = [
|
||||
self._openid_capability_flag,
|
||||
utils.lc_int(len(openid_token)),
|
||||
openid_token.encode(),
|
||||
]
|
||||
return b"".join(auth_response)
|
||||
except (SyntaxError, TypeError, OSError, UnicodeError) as err:
|
||||
raise errors.ProgrammingError(
|
||||
"The OpenID Connect Token File (openid_token_file) could not be read"
|
||||
) from err
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: MySQLSocket, auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If a NULL auth response is received from auth_response method.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
packet = sock.recv()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""WebAuthn Authentication Plugin."""
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
from .. import errors, utils
|
||||
from ..logger import logger
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
from fido2.cbor import dump_bytes as cbor_dump_bytes
|
||||
from fido2.client import Fido2Client, UserInteraction
|
||||
from fido2.hid import CtapHidDevice
|
||||
from fido2.webauthn import PublicKeyCredentialRequestOptions
|
||||
except ImportError as import_err:
|
||||
raise errors.ProgrammingError(
|
||||
"Module fido2 is required for WebAuthn authentication mechanism but was "
|
||||
"not found. Unable to authenticate with the server"
|
||||
) from import_err
|
||||
|
||||
try:
|
||||
from fido2.pcsc import CtapPcscDevice
|
||||
|
||||
CTAP_PCSC_DEVICE_AVAILABLE = True
|
||||
except ModuleNotFoundError:
|
||||
CTAP_PCSC_DEVICE_AVAILABLE = False
|
||||
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLWebAuthnAuthPlugin"
|
||||
|
||||
|
||||
class ClientInteraction(UserInteraction):
|
||||
"""Provides user interaction to the Client."""
|
||||
|
||||
def __init__(self, callback: Optional[Callable] = None):
|
||||
self.callback = callback
|
||||
self.msg = (
|
||||
"Please insert FIDO device and perform gesture action for authentication "
|
||||
"to complete."
|
||||
)
|
||||
|
||||
def prompt_up(self) -> None:
|
||||
"""Prompt message for the user interaction with the FIDO device."""
|
||||
if self.callback is None:
|
||||
print(self.msg)
|
||||
else:
|
||||
self.callback(self.msg)
|
||||
|
||||
|
||||
class MySQLWebAuthnAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL WebAuthn authentication plugin."""
|
||||
|
||||
client: Optional[Fido2Client] = None
|
||||
callback: Optional[Callable] = None
|
||||
options: dict = {"rpId": None, "challenge": None, "allowCredentials": []}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_webauthn_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def get_assertion_response(
|
||||
self, credential_id: Optional[bytearray] = None
|
||||
) -> bytes:
|
||||
"""Get assertion from authenticator and return the response.
|
||||
|
||||
Args:
|
||||
credential_id (Optional[bytearray]): The credential ID.
|
||||
|
||||
Returns:
|
||||
bytearray: The response packet with the data from the assertion.
|
||||
"""
|
||||
if self.client is None:
|
||||
raise errors.InterfaceError("No WebAuthn client found")
|
||||
|
||||
if credential_id is not None:
|
||||
# If credential_id is not None, it's because the FIDO device does not
|
||||
# support resident keys and the credential_id was requested from the server
|
||||
self.options["allowCredentials"] = [
|
||||
{
|
||||
"id": credential_id,
|
||||
"type": "public-key",
|
||||
}
|
||||
]
|
||||
|
||||
# Get assertion from authenticator
|
||||
assertion = self.client.get_assertion(
|
||||
PublicKeyCredentialRequestOptions.from_dict(self.options)
|
||||
)
|
||||
number_of_assertions = len(assertion.get_assertions())
|
||||
client_data_json = b""
|
||||
|
||||
# Build response packet
|
||||
#
|
||||
# Format:
|
||||
# int<1> 0x02 (2) status tag
|
||||
# int<lenenc> number of assertions length encoded number of assertions
|
||||
# string authenticator data variable length raw binary string
|
||||
# string signed challenge variable length raw binary string
|
||||
# ...
|
||||
# ...
|
||||
# string authenticator data variable length raw binary string
|
||||
# string signed challenge variable length raw binary string
|
||||
# string ClientDataJSON variable length raw binary string
|
||||
packet = utils.lc_int(2)
|
||||
packet += utils.lc_int(number_of_assertions)
|
||||
|
||||
# Add authenticator data and signed challenge for each assertion
|
||||
for i in range(number_of_assertions):
|
||||
assertion_response = assertion.get_response(i)
|
||||
|
||||
# string<lenenc> authenticator_data
|
||||
authenticator_data = cbor_dump_bytes(assertion_response.authenticator_data)
|
||||
|
||||
# string<lenenc> signed_challenge
|
||||
signature = assertion_response.signature
|
||||
|
||||
packet += utils.lc_int(len(authenticator_data))
|
||||
packet += authenticator_data
|
||||
packet += utils.lc_int(len(signature))
|
||||
packet += signature
|
||||
|
||||
# string<lenenc> client_data_json
|
||||
client_data_json = assertion_response.client_data
|
||||
|
||||
packet += utils.lc_int(len(client_data_json))
|
||||
packet += client_data_json
|
||||
|
||||
logger.debug("WebAuthn - payload response packet: %s", packet)
|
||||
return packet
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Find authenticator device and check if supports resident keys.
|
||||
|
||||
It also creates a Fido2Client using the relying party ID from the server.
|
||||
|
||||
Raises:
|
||||
InterfaceError: When the FIDO device is not found.
|
||||
|
||||
Returns:
|
||||
bytes: 2 if the authenticator supports resident keys else 1.
|
||||
"""
|
||||
try:
|
||||
packets, capability = utils.read_int(auth_data, 1)
|
||||
challenge, rp_id = utils.read_lc_string_list(packets)
|
||||
self.options["challenge"] = challenge
|
||||
self.options["rpId"] = rp_id.decode()
|
||||
logger.debug("WebAuthn - capability: %d", capability)
|
||||
logger.debug("WebAuthn - challenge: %s", self.options["challenge"])
|
||||
logger.debug("WebAuthn - relying party id: %s", self.options["rpId"])
|
||||
except ValueError as err:
|
||||
raise errors.InterfaceError(
|
||||
"Unable to parse MySQL WebAuthn authentication data"
|
||||
) from err
|
||||
|
||||
# Locate a device
|
||||
device = next(CtapHidDevice.list_devices(), None)
|
||||
if device is not None:
|
||||
logger.debug("WebAuthn - Use USB HID channel")
|
||||
elif CTAP_PCSC_DEVICE_AVAILABLE:
|
||||
device = next(CtapPcscDevice.list_devices(), None)
|
||||
|
||||
if device is None:
|
||||
raise errors.InterfaceError("No FIDO device found")
|
||||
|
||||
# Set up a FIDO 2 client using the origin relying party id
|
||||
self.client = Fido2Client(
|
||||
device,
|
||||
f"https://{self.options['rpId']}",
|
||||
user_interaction=ClientInteraction(self.callback),
|
||||
)
|
||||
|
||||
if not self.client.info.options.get("rk"):
|
||||
logger.debug("WebAuthn - Authenticator doesn't support resident keys")
|
||||
return b"1"
|
||||
|
||||
logger.debug("WebAuthn - Authenticator with support for resident key found")
|
||||
return b"2"
|
||||
|
||||
def auth_more_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth more data` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Authentication method data (from a packet representing
|
||||
an `auth more data` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
_, credential_id = utils.read_lc_string(auth_data)
|
||||
|
||||
response = self.get_assertion_response(credential_id) # type: ignore[arg-type]
|
||||
|
||||
logger.debug("WebAuthn - request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
pkt = bytes(sock.recv())
|
||||
logger.debug("WebAuthn - server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
webauth_callback = kwargs.get("webauthn_callback") or kwargs.get(
|
||||
"fido_callback"
|
||||
)
|
||||
self.callback = (
|
||||
utils.import_object(webauth_callback)
|
||||
if isinstance(webauth_callback, str)
|
||||
else webauth_callback
|
||||
)
|
||||
|
||||
response = self.auth_response(auth_data)
|
||||
credential_id = None
|
||||
|
||||
if response == b"1":
|
||||
# Authenticator doesn't support resident keys, request credential_id
|
||||
logger.debug("WebAuthn - request credential_id")
|
||||
sock.send(utils.lc_int(int(response)))
|
||||
|
||||
# return a packet representing an `auth more data` response
|
||||
return bytes(sock.recv())
|
||||
|
||||
response = self.get_assertion_response(credential_id)
|
||||
|
||||
logger.debug("WebAuthn - request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
pkt = bytes(sock.recv())
|
||||
logger.debug("WebAuthn - server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,159 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Caching SHA2 Password Authentication Plugin."""
|
||||
|
||||
import struct
|
||||
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from ..errors import InterfaceError
|
||||
from ..logger import logger
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLCachingSHA2PasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLCachingSHA2PasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL caching_sha2_password authentication plugin
|
||||
|
||||
Note that encrypting using RSA is not supported since the Python
|
||||
Standard Library does not provide this OpenSSL functionality.
|
||||
"""
|
||||
|
||||
perform_full_authentication: int = 4
|
||||
|
||||
def _scramble(self, auth_data: bytes) -> bytes:
|
||||
"""Return a scramble of the password using a Nonce sent by the
|
||||
server.
|
||||
|
||||
The scramble is of the form:
|
||||
XOR(SHA2(password), SHA2(SHA2(SHA2(password)), Nonce))
|
||||
"""
|
||||
if not auth_data:
|
||||
raise InterfaceError("Missing authentication data (seed)")
|
||||
|
||||
if not self._password:
|
||||
return b""
|
||||
|
||||
hash1 = sha256(self._password.encode()).digest()
|
||||
hash2 = sha256()
|
||||
hash2.update(sha256(hash1).digest())
|
||||
hash2.update(auth_data)
|
||||
hash2_digest = hash2.digest()
|
||||
xored = [h1 ^ h2 for (h1, h2) in zip(hash1, hash2_digest)]
|
||||
hash3 = struct.pack("32B", *xored)
|
||||
return hash3
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "caching_sha2_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Make the client's authorization response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
"""
|
||||
if not auth_data:
|
||||
return None
|
||||
if len(auth_data) > 1:
|
||||
return self._scramble(auth_data)
|
||||
if auth_data[0] == self.perform_full_authentication:
|
||||
# return password as clear text.
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
return None
|
||||
|
||||
def auth_more_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth more data` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Authentication method data (from a packet representing
|
||||
an `auth more data` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response:
|
||||
sock.send(response)
|
||||
|
||||
return bytes(sock.recv())
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
pkt = bytes(sock.recv())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,104 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Clear Password Authentication Plugin."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from .. import errors
|
||||
from ..logger import logger
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLClearPasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLClearPasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL Clear Password authentication plugin"""
|
||||
|
||||
def _prepare_password(self) -> bytes:
|
||||
"""Prepare and return password as as clear text.
|
||||
|
||||
Returns:
|
||||
bytes: Prepared password.
|
||||
"""
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "mysql_clear_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return True
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Return the prepared password to send to MySQL.
|
||||
|
||||
Raises:
|
||||
InterfaceError: When SSL is required by not enabled.
|
||||
|
||||
Returns:
|
||||
str: The prepared password.
|
||||
"""
|
||||
if self.requires_ssl and not self._ssl_enabled:
|
||||
raise errors.InterfaceError(f"{self.name} requires SSL")
|
||||
return self._prepare_password()
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
pkt = bytes(sock.recv())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Native Password Authentication Plugin."""
|
||||
|
||||
import struct
|
||||
|
||||
from hashlib import sha1
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from ..errors import InterfaceError
|
||||
from ..logger import logger
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLNativePasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLNativePasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL Native Password authentication plugin"""
|
||||
|
||||
def _prepare_password(self, auth_data: bytes) -> bytes:
|
||||
"""Prepares and returns password as native MySQL 4.1+ password"""
|
||||
if not auth_data:
|
||||
raise InterfaceError("Missing authentication data (seed)")
|
||||
|
||||
if not self._password:
|
||||
return b""
|
||||
|
||||
hash4 = None
|
||||
try:
|
||||
hash1 = sha1(self._password.encode()).digest()
|
||||
hash2 = sha1(hash1).digest()
|
||||
hash3 = sha1(auth_data + hash2).digest()
|
||||
xored = [h1 ^ h3 for (h1, h3) in zip(hash1, hash3)]
|
||||
hash4 = struct.pack("20B", *xored)
|
||||
except (struct.error, TypeError) as err:
|
||||
raise InterfaceError(f"Failed scrambling password; {err}") from err
|
||||
|
||||
return hash4
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "mysql_native_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Make the client's authorization response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
"""
|
||||
return self._prepare_password(auth_data)
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
pkt = bytes(sock.recv())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,108 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""SHA256 Password Authentication Plugin."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from .. import errors
|
||||
from ..logger import logger
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLSHA256PasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLSHA256PasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL SHA256 authentication plugin
|
||||
|
||||
Note that encrypting using RSA is not supported since the Python
|
||||
Standard Library does not provide this OpenSSL functionality.
|
||||
"""
|
||||
|
||||
def _prepare_password(self) -> bytes:
|
||||
"""Prepare and return password as as clear text.
|
||||
|
||||
Returns:
|
||||
password (bytes): Prepared password.
|
||||
"""
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "sha256_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return True
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Return the prepared password to send to MySQL.
|
||||
|
||||
Raises:
|
||||
InterfaceError: When SSL is required by not enabled.
|
||||
|
||||
Returns:
|
||||
str: The prepared password.
|
||||
"""
|
||||
if self.requires_ssl and not self.ssl_enabled:
|
||||
raise errors.InterfaceError(f"{self.name} requires SSL")
|
||||
return self._prepare_password()
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
pkt = bytes(sock.recv())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,699 @@
|
||||
# Copyright (c) 2013, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Implementing pooling of connections to MySQL servers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, Any, Dict, NoReturn, Optional, Tuple, Type, Union
|
||||
from uuid import uuid4
|
||||
|
||||
try:
|
||||
import dns.exception
|
||||
import dns.resolver
|
||||
except ImportError:
|
||||
HAVE_DNSPYTHON = False
|
||||
else:
|
||||
HAVE_DNSPYTHON = True
|
||||
|
||||
try:
|
||||
from .connection_cext import CMySQLConnection
|
||||
except ImportError:
|
||||
CMySQLConnection = None # type: ignore[misc]
|
||||
|
||||
from .connection import MySQLConnection
|
||||
from .constants import CNX_POOL_ARGS, DEFAULT_CONFIGURATION
|
||||
from .errors import (
|
||||
Error,
|
||||
InterfaceError,
|
||||
NotSupportedError,
|
||||
PoolError,
|
||||
ProgrammingError,
|
||||
)
|
||||
from .optionfiles import read_option_files
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .abstracts import MySQLConnectionAbstract
|
||||
|
||||
CONNECTION_POOL_LOCK = threading.RLock()
|
||||
CNX_POOL_MAXSIZE = 32
|
||||
CNX_POOL_MAXNAMESIZE = 64
|
||||
CNX_POOL_NAMEREGEX = re.compile(r"[^a-zA-Z0-9._:\-*$#]")
|
||||
ERROR_NO_CEXT = "MySQL Connector/Python C Extension not available"
|
||||
MYSQL_CNX_CLASS: Union[type, Tuple[type, ...]] = (
|
||||
MySQLConnection if CMySQLConnection is None else (MySQLConnection, CMySQLConnection)
|
||||
)
|
||||
|
||||
_CONNECTION_POOLS: Dict[str, MySQLConnectionPool] = {}
|
||||
|
||||
|
||||
def _get_pooled_connection(**kwargs: Any) -> PooledMySQLConnection:
|
||||
"""Return a pooled MySQL connection."""
|
||||
# If no pool name specified, generate one
|
||||
pool_name = (
|
||||
kwargs["pool_name"] if "pool_name" in kwargs else generate_pool_name(**kwargs)
|
||||
)
|
||||
|
||||
if kwargs.get("use_pure") is False and CMySQLConnection is None:
|
||||
raise ImportError(ERROR_NO_CEXT)
|
||||
|
||||
# Setup the pool, ensuring only 1 thread can update at a time
|
||||
with CONNECTION_POOL_LOCK:
|
||||
if pool_name not in _CONNECTION_POOLS:
|
||||
_CONNECTION_POOLS[pool_name] = MySQLConnectionPool(**kwargs)
|
||||
elif isinstance(_CONNECTION_POOLS[pool_name], MySQLConnectionPool):
|
||||
# pool_size must be the same
|
||||
check_size = _CONNECTION_POOLS[pool_name].pool_size
|
||||
if "pool_size" in kwargs and kwargs["pool_size"] != check_size:
|
||||
raise PoolError("Size can not be changed for active pools.")
|
||||
|
||||
# Return pooled connection
|
||||
try:
|
||||
return _CONNECTION_POOLS[pool_name].get_connection()
|
||||
except AttributeError:
|
||||
raise InterfaceError(
|
||||
f"Failed getting connection from pool '{pool_name}'"
|
||||
) from None
|
||||
|
||||
|
||||
def _get_failover_connection(
|
||||
**kwargs: Any,
|
||||
) -> Union[PooledMySQLConnection, MySQLConnectionAbstract]:
|
||||
"""Return a MySQL connection and try to failover if needed.
|
||||
|
||||
An InterfaceError is raise when no MySQL is available. ValueError is
|
||||
raised when the failover server configuration contains an illegal
|
||||
connection argument. Supported arguments are user, password, host, port,
|
||||
unix_socket and database. ValueError is also raised when the failover
|
||||
argument was not provided.
|
||||
|
||||
Returns MySQLConnection instance.
|
||||
"""
|
||||
config = kwargs.copy()
|
||||
try:
|
||||
failover = config["failover"]
|
||||
except KeyError:
|
||||
raise ValueError("failover argument not provided") from None
|
||||
del config["failover"]
|
||||
|
||||
support_cnx_args = set(
|
||||
[
|
||||
"user",
|
||||
"password",
|
||||
"host",
|
||||
"port",
|
||||
"unix_socket",
|
||||
"database",
|
||||
"pool_name",
|
||||
"pool_size",
|
||||
"priority",
|
||||
]
|
||||
)
|
||||
|
||||
# First check if we can add all use the configuration
|
||||
priority_count = 0
|
||||
for server in failover:
|
||||
diff = set(server.keys()) - support_cnx_args
|
||||
if diff:
|
||||
arg = "s" if len(diff) > 1 else ""
|
||||
lst = ", ".join(diff)
|
||||
raise ValueError(
|
||||
f"Unsupported connection argument {arg} in failover: {lst}"
|
||||
)
|
||||
if hasattr(server, "priority"):
|
||||
priority_count += 1
|
||||
|
||||
server["priority"] = server.get("priority", 100)
|
||||
if server["priority"] < 0 or server["priority"] > 100:
|
||||
raise InterfaceError(
|
||||
"Priority value should be in the range of 0 to 100, "
|
||||
f"got : {server['priority']}"
|
||||
)
|
||||
if not isinstance(server["priority"], int):
|
||||
raise InterfaceError(
|
||||
"Priority value should be an integer in the range of 0 to "
|
||||
f"100, got : {server['priority']}"
|
||||
)
|
||||
|
||||
if 0 < priority_count < len(failover):
|
||||
raise ProgrammingError(
|
||||
"You must either assign no priority to any "
|
||||
"of the routers or give a priority for "
|
||||
"every router"
|
||||
)
|
||||
|
||||
server_directory = {}
|
||||
server_priority_list = []
|
||||
for server in sorted(failover, key=lambda x: x["priority"], reverse=True):
|
||||
if server["priority"] not in server_directory:
|
||||
server_directory[server["priority"]] = [server]
|
||||
server_priority_list.append(server["priority"])
|
||||
else:
|
||||
server_directory[server["priority"]].append(server)
|
||||
|
||||
for priority in server_priority_list:
|
||||
failover_list = server_directory[priority]
|
||||
for _ in range(len(failover_list)):
|
||||
last = len(failover_list) - 1
|
||||
index = random.randint(0, last)
|
||||
server = failover_list.pop(index)
|
||||
new_config = config.copy()
|
||||
new_config.update(server)
|
||||
new_config.pop("priority", None)
|
||||
try:
|
||||
return connect(**new_config)
|
||||
except Error:
|
||||
# If we failed to connect, we try the next server
|
||||
pass
|
||||
|
||||
raise InterfaceError("Unable to connect to any of the target hosts")
|
||||
|
||||
|
||||
def connect(
|
||||
*args: Any, **kwargs: Any
|
||||
) -> Union[PooledMySQLConnection, MySQLConnectionAbstract]:
|
||||
"""Creates or gets a MySQL connection object.
|
||||
|
||||
In its simpliest form, `connect()` will open a connection to a
|
||||
MySQL server and return a `MySQLConnectionAbstract` subclass
|
||||
object such as `MySQLConnection` or `CMySQLConnection`.
|
||||
|
||||
When any connection pooling arguments are given, for example `pool_name`
|
||||
or `pool_size`, a pool is created or a previously one is used to return
|
||||
a `PooledMySQLConnection`.
|
||||
|
||||
Args:
|
||||
*args: N/A.
|
||||
**kwargs: For a complete list of possible arguments, see [1]. If no arguments
|
||||
are given, it uses the already configured or default values.
|
||||
|
||||
Returns:
|
||||
A `MySQLConnectionAbstract` subclass instance (such as `MySQLConnection` or
|
||||
`CMySQLConnection`) or a `PooledMySQLConnection` instance.
|
||||
|
||||
Examples:
|
||||
A connection with the MySQL server can be established using either the
|
||||
`mysql.connector.connect()` method or a `MySQLConnectionAbstract` subclass:
|
||||
```
|
||||
>>> from mysql.connector import MySQLConnection, HAVE_CEXT
|
||||
>>>
|
||||
>>> cnx1 = mysql.connector.connect(user='joe', database='test')
|
||||
>>> cnx2 = MySQLConnection(user='joe', database='test')
|
||||
>>>
|
||||
>>> cnx3 = None
|
||||
>>> if HAVE_CEXT:
|
||||
>>> from mysql.connector import CMySQLConnection
|
||||
>>> cnx3 = CMySQLConnection(user='joe', database='test')
|
||||
```
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
# DNS SRV
|
||||
dns_srv = kwargs.pop("dns_srv") if "dns_srv" in kwargs else False
|
||||
|
||||
if not isinstance(dns_srv, bool):
|
||||
raise InterfaceError("The value of 'dns-srv' must be a boolean")
|
||||
|
||||
if dns_srv:
|
||||
if not HAVE_DNSPYTHON:
|
||||
raise InterfaceError(
|
||||
"MySQL host configuration requested DNS "
|
||||
"SRV. This requires the Python dnspython "
|
||||
"module. Please refer to documentation"
|
||||
)
|
||||
if "unix_socket" in kwargs:
|
||||
raise InterfaceError(
|
||||
"Using Unix domain sockets with DNS SRV lookup is not allowed"
|
||||
)
|
||||
if "port" in kwargs:
|
||||
raise InterfaceError(
|
||||
"Specifying a port number with DNS SRV lookup is not allowed"
|
||||
)
|
||||
if "failover" in kwargs:
|
||||
raise InterfaceError(
|
||||
"Specifying multiple hostnames with DNS SRV look up is not allowed"
|
||||
)
|
||||
if "host" not in kwargs:
|
||||
kwargs["host"] = DEFAULT_CONFIGURATION["host"]
|
||||
|
||||
try:
|
||||
srv_records = dns.resolver.query(kwargs["host"], "SRV")
|
||||
except dns.exception.DNSException:
|
||||
raise InterfaceError(
|
||||
f"Unable to locate any hosts for '{kwargs['host']}'"
|
||||
) from None
|
||||
|
||||
failover = []
|
||||
for srv in srv_records:
|
||||
failover.append(
|
||||
{
|
||||
"host": srv.target.to_text(omit_final_dot=True),
|
||||
"port": srv.port,
|
||||
"priority": srv.priority,
|
||||
"weight": srv.weight,
|
||||
}
|
||||
)
|
||||
|
||||
failover.sort(key=lambda x: (x["priority"], -x["weight"]))
|
||||
kwargs["failover"] = [
|
||||
{"host": srv["host"], "port": srv["port"]} for srv in failover
|
||||
]
|
||||
|
||||
# Option files
|
||||
if "read_default_file" in kwargs:
|
||||
kwargs["option_files"] = kwargs["read_default_file"]
|
||||
kwargs.pop("read_default_file")
|
||||
|
||||
if "option_files" in kwargs:
|
||||
new_config = read_option_files(**kwargs)
|
||||
return connect(**new_config)
|
||||
|
||||
# Failover
|
||||
if "failover" in kwargs:
|
||||
return _get_failover_connection(**kwargs)
|
||||
|
||||
# Pooled connections
|
||||
try:
|
||||
if any(key in kwargs for key in CNX_POOL_ARGS):
|
||||
return _get_pooled_connection(**kwargs)
|
||||
except NameError:
|
||||
# No pooling
|
||||
pass
|
||||
|
||||
# Use C Extension by default
|
||||
use_pure = kwargs.get("use_pure", False)
|
||||
if "use_pure" in kwargs:
|
||||
del kwargs["use_pure"] # Remove 'use_pure' from kwargs
|
||||
if not use_pure and CMySQLConnection is None:
|
||||
raise ImportError(ERROR_NO_CEXT)
|
||||
|
||||
if CMySQLConnection and not use_pure:
|
||||
return CMySQLConnection(*args, **kwargs)
|
||||
return MySQLConnection(*args, **kwargs)
|
||||
|
||||
|
||||
def generate_pool_name(**kwargs: Any) -> str:
|
||||
"""Generate a pool name
|
||||
|
||||
This function takes keyword arguments, usually the connection
|
||||
arguments for MySQLConnection, and tries to generate a name for
|
||||
a pool.
|
||||
|
||||
Raises PoolError when no name can be generated.
|
||||
|
||||
Returns a string.
|
||||
"""
|
||||
parts = []
|
||||
for key in ("host", "port", "user", "database"):
|
||||
try:
|
||||
parts.append(str(kwargs[key]))
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if not parts:
|
||||
raise PoolError("Failed generating pool name; specify pool_name")
|
||||
|
||||
return "_".join(parts)
|
||||
|
||||
|
||||
class PooledMySQLConnection:
|
||||
"""Class holding a MySQL Connection in a pool
|
||||
|
||||
PooledMySQLConnection is used by MySQLConnectionPool to return an
|
||||
instance holding a MySQL connection. It works like a MySQLConnection
|
||||
except for methods like close() and config().
|
||||
|
||||
The close()-method will add the connection back to the pool rather
|
||||
than disconnecting from the MySQL server.
|
||||
|
||||
Configuring the connection have to be done through the MySQLConnectionPool
|
||||
method set_config(). Using config() on pooled connection will raise a
|
||||
PoolError.
|
||||
|
||||
Attributes:
|
||||
pool_name (str): Returns the name of the connection pool to which the
|
||||
connection belongs.
|
||||
"""
|
||||
|
||||
def __init__(self, pool: MySQLConnectionPool, cnx: MySQLConnectionAbstract) -> None:
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
pool: A `MySQLConnectionPool` instance.
|
||||
cnx: A `MySQLConnectionAbstract` subclass instance.
|
||||
"""
|
||||
if not isinstance(pool, MySQLConnectionPool):
|
||||
raise AttributeError("pool should be a MySQLConnectionPool")
|
||||
if not isinstance(cnx, MYSQL_CNX_CLASS):
|
||||
raise AttributeError("cnx should be a MySQLConnection")
|
||||
self._cnx_pool: MySQLConnectionPool = pool
|
||||
self._cnx: MySQLConnectionAbstract = cnx
|
||||
|
||||
def __enter__(self) -> PooledMySQLConnection:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Type[BaseException],
|
||||
exc_value: BaseException,
|
||||
traceback: TracebackType,
|
||||
) -> None:
|
||||
self.close()
|
||||
|
||||
def __getattr__(self, attr: Any) -> Any:
|
||||
"""Calls attributes of the MySQLConnection instance"""
|
||||
return getattr(self._cnx, attr)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Do not close, but adds connection back to pool.
|
||||
|
||||
For a pooled connection, close() does not actually close it but returns it
|
||||
to the pool and makes it available for subsequent connection requests. If the
|
||||
pool configuration parameters are changed, a returned connection is closed
|
||||
and reopened with the new configuration before being returned from the pool
|
||||
again in response to a connection request.
|
||||
"""
|
||||
try:
|
||||
cnx = self._cnx
|
||||
if self._cnx_pool.reset_session:
|
||||
cnx.reset_session()
|
||||
finally:
|
||||
self._cnx_pool.add_connection(cnx)
|
||||
self._cnx = None
|
||||
|
||||
@staticmethod
|
||||
def config(**kwargs: Any) -> NoReturn:
|
||||
"""Configuration is done through the pool.
|
||||
|
||||
For pooled connections, the `config()` method raises a `PoolError`
|
||||
exception. Configuration for pooled connections should be done
|
||||
using the pool object.
|
||||
"""
|
||||
raise PoolError(
|
||||
"Configuration for pooled connections should be done through the "
|
||||
"pool itself"
|
||||
)
|
||||
|
||||
@property
|
||||
def pool_name(self) -> str:
|
||||
"""Returns the name of the connection pool to which the connection belongs."""
|
||||
return self._cnx_pool.pool_name
|
||||
|
||||
|
||||
class MySQLConnectionPool:
|
||||
"""Class defining a pool of MySQL connections"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pool_size: int = 5,
|
||||
pool_name: Optional[str] = None,
|
||||
pool_reset_session: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Constructor.
|
||||
|
||||
Initialize a MySQL connection pool with a maximum number of
|
||||
connections set to `pool_size`. The rest of the keywords
|
||||
arguments, kwargs, are configuration arguments for MySQLConnection
|
||||
instances.
|
||||
|
||||
Args:
|
||||
pool_name: The pool name. If this argument is not given, Connector/Python
|
||||
automatically generates the name, composed from whichever of
|
||||
the host, port, user, and database connection arguments are
|
||||
given in kwargs, in that order.
|
||||
pool_size: The pool size. If this argument is not given, the default is 5.
|
||||
pool_reset_session: Whether to reset session variables when the connection
|
||||
is returned to the pool.
|
||||
**kwargs: Optional additional connection arguments, as described in [1].
|
||||
|
||||
Examples:
|
||||
```
|
||||
>>> dbconfig = {
|
||||
>>> "database": "test",
|
||||
>>> "user": "joe",
|
||||
>>> }
|
||||
>>> cnxpool = mysql.connector.pooling.MySQLConnectionPool(pool_name = "mypool",
|
||||
>>> pool_size = 3,
|
||||
>>> **dbconfig)
|
||||
```
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
self._pool_size: Optional[int] = None
|
||||
self._pool_name: Optional[str] = None
|
||||
self._reset_session = pool_reset_session
|
||||
self._set_pool_size(pool_size)
|
||||
self._set_pool_name(pool_name or generate_pool_name(**kwargs))
|
||||
self._cnx_config: Dict[str, Any] = {}
|
||||
self._cnx_queue: queue.Queue[MySQLConnectionAbstract] = queue.Queue(
|
||||
self._pool_size
|
||||
)
|
||||
self._config_version = uuid4()
|
||||
|
||||
if kwargs:
|
||||
self.set_config(**kwargs)
|
||||
cnt = 0
|
||||
while cnt < self._pool_size:
|
||||
self.add_connection()
|
||||
cnt += 1
|
||||
|
||||
@property
|
||||
def pool_name(self) -> str:
|
||||
"""Returns the name of the connection pool."""
|
||||
return self._pool_name
|
||||
|
||||
@property
|
||||
def pool_size(self) -> int:
|
||||
"""Returns number of connections managed by the pool."""
|
||||
return self._pool_size
|
||||
|
||||
@property
|
||||
def reset_session(self) -> bool:
|
||||
"""Returns whether to reset session."""
|
||||
return self._reset_session
|
||||
|
||||
def set_config(self, **kwargs: Any) -> None:
|
||||
"""Set the connection configuration for `MySQLConnectionAbstract` subclass instances.
|
||||
|
||||
This method sets the configuration used for creating `MySQLConnectionAbstract`
|
||||
subclass instances such as `MySQLConnection`. See [1] for valid
|
||||
connection arguments.
|
||||
|
||||
Args:
|
||||
**kwargs: Connection arguments - for a complete list of possible
|
||||
arguments, see [1].
|
||||
|
||||
Raises:
|
||||
PoolError: When a connection argument is not valid, missing
|
||||
or not supported by `MySQLConnectionAbstract`.
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
if not kwargs:
|
||||
return
|
||||
|
||||
with CONNECTION_POOL_LOCK:
|
||||
try:
|
||||
test_cnx = connect()
|
||||
test_cnx.config(**kwargs)
|
||||
self._cnx_config = kwargs
|
||||
self._config_version = uuid4()
|
||||
except AttributeError as err:
|
||||
raise PoolError(f"Connection configuration not valid: {err}") from err
|
||||
|
||||
def _set_pool_size(self, pool_size: int) -> None:
|
||||
"""Set the size of the pool
|
||||
|
||||
This method sets the size of the pool but it will not resize the pool.
|
||||
|
||||
Raises an AttributeError when the pool_size is not valid. Invalid size
|
||||
is 0, negative or higher than pooling.CNX_POOL_MAXSIZE.
|
||||
"""
|
||||
if pool_size <= 0 or pool_size > CNX_POOL_MAXSIZE:
|
||||
raise AttributeError(
|
||||
"Pool size should be higher than 0 and lower or equal to "
|
||||
f"{CNX_POOL_MAXSIZE}"
|
||||
)
|
||||
self._pool_size = pool_size
|
||||
|
||||
def _set_pool_name(self, pool_name: str) -> None:
|
||||
r"""Set the name of the pool.
|
||||
|
||||
This method checks the validity and sets the name of the pool.
|
||||
|
||||
Raises an AttributeError when pool_name contains illegal characters
|
||||
([^a-zA-Z0-9._\-*$#]) or is longer than pooling.CNX_POOL_MAXNAMESIZE.
|
||||
"""
|
||||
if CNX_POOL_NAMEREGEX.search(pool_name):
|
||||
raise AttributeError(f"Pool name '{pool_name}' contains illegal characters")
|
||||
if len(pool_name) > CNX_POOL_MAXNAMESIZE:
|
||||
raise AttributeError(f"Pool name '{pool_name}' is too long")
|
||||
self._pool_name = pool_name
|
||||
|
||||
def _queue_connection(self, cnx: MySQLConnectionAbstract) -> None:
|
||||
"""Put connection back in the queue
|
||||
|
||||
This method is putting a connection back in the queue. It will not
|
||||
acquire a lock as the methods using _queue_connection() will have it
|
||||
set.
|
||||
|
||||
Raises `PoolError` on errors.
|
||||
"""
|
||||
if not isinstance(cnx, MYSQL_CNX_CLASS):
|
||||
raise PoolError(
|
||||
"Connection instance not subclass of MySQLConnectionAbstract"
|
||||
)
|
||||
|
||||
try:
|
||||
self._cnx_queue.put(cnx, block=False)
|
||||
except queue.Full as err:
|
||||
raise PoolError("Failed adding connection; queue is full") from err
|
||||
|
||||
def add_connection(self, cnx: Optional[MySQLConnectionAbstract] = None) -> None:
|
||||
"""Adds a connection to the pool.
|
||||
|
||||
This method instantiates a `MySQLConnection` using the configuration
|
||||
passed when initializing the `MySQLConnectionPool` instance or using
|
||||
the `set_config()` method.
|
||||
If cnx is a `MySQLConnection` instance, it will be added to the
|
||||
queue.
|
||||
|
||||
Args:
|
||||
cnx: The `MySQLConnectionAbstract` subclass object to be added to
|
||||
the pool. If this argument is missing (aka `None`), the pool
|
||||
creates a new connection and adds it.
|
||||
|
||||
Raises:
|
||||
PoolError: When no configuration is set, when no more
|
||||
connection can be added (maximum reached) or when the connection
|
||||
can not be instantiated.
|
||||
"""
|
||||
with CONNECTION_POOL_LOCK:
|
||||
if not self._cnx_config:
|
||||
raise PoolError("Connection configuration not available")
|
||||
|
||||
if self._cnx_queue.full():
|
||||
raise PoolError("Failed adding connection; queue is full")
|
||||
|
||||
if not cnx:
|
||||
cnx = connect(**self._cnx_config) # type: ignore[assignment]
|
||||
try:
|
||||
if (
|
||||
self._reset_session
|
||||
and self._cnx_config["compress"]
|
||||
and cnx.server_version < (5, 7, 3)
|
||||
):
|
||||
raise NotSupportedError(
|
||||
"Pool reset session is not supported with "
|
||||
"compression for MySQL server version 5.7.2 "
|
||||
"or earlier"
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
cnx.pool_config_version = self._config_version
|
||||
else:
|
||||
if not isinstance(cnx, MYSQL_CNX_CLASS):
|
||||
raise PoolError(
|
||||
"Connection instance not subclass of MySQLConnectionAbstract"
|
||||
)
|
||||
|
||||
self._queue_connection(cnx)
|
||||
|
||||
def get_connection(self) -> PooledMySQLConnection:
|
||||
"""Gets a connection from the pool.
|
||||
|
||||
This method returns an PooledMySQLConnection instance which
|
||||
has a reference to the pool that created it, and the next available
|
||||
MySQL connection.
|
||||
|
||||
When the MySQL connection is not connect, a reconnect is attempted.
|
||||
|
||||
Returns:
|
||||
A `PooledMySQLConnection` instance.
|
||||
|
||||
Raises:
|
||||
PoolError: On errors.
|
||||
"""
|
||||
with CONNECTION_POOL_LOCK:
|
||||
try:
|
||||
cnx = self._cnx_queue.get(block=False)
|
||||
except queue.Empty as err:
|
||||
raise PoolError("Failed getting connection; pool exhausted") from err
|
||||
|
||||
if (
|
||||
not cnx.is_connected()
|
||||
or self._config_version != cnx.pool_config_version
|
||||
):
|
||||
cnx.config(**self._cnx_config)
|
||||
try:
|
||||
cnx.reconnect()
|
||||
except InterfaceError:
|
||||
# Failed to reconnect, give connection back to pool
|
||||
self._queue_connection(cnx)
|
||||
raise
|
||||
cnx.pool_config_version = self._config_version
|
||||
|
||||
return PooledMySQLConnection(self, cnx)
|
||||
|
||||
def _remove_connections(self) -> int:
|
||||
"""Close all connections
|
||||
|
||||
This method closes all connections. It returns the number
|
||||
of connections it closed.
|
||||
|
||||
Used mostly for tests.
|
||||
|
||||
Returns int.
|
||||
"""
|
||||
with CONNECTION_POOL_LOCK:
|
||||
cnt = 0
|
||||
cnxq = self._cnx_queue
|
||||
while cnxq.qsize():
|
||||
try:
|
||||
cnx = cnxq.get(block=False)
|
||||
cnx.disconnect()
|
||||
cnt += 1
|
||||
except queue.Empty:
|
||||
return cnt
|
||||
except PoolError:
|
||||
raise
|
||||
except Error:
|
||||
# Any other error when closing means connection is closed
|
||||
pass
|
||||
|
||||
return cnt
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
@@ -0,0 +1,168 @@
|
||||
# Copyright (c) 2024, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""TLS ciphersuites and versions."""
|
||||
|
||||
# Generated from the OSSA cipher list
|
||||
# version: 3.4
|
||||
# date: 2024-04-11
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
APPROVED_TLS_VERSIONS: List[str] = ["TLSv1.2", "TLSv1.3"]
|
||||
"""Approved TLS versions."""
|
||||
|
||||
DEPRECATED_TLS_VERSIONS: List[str] = []
|
||||
"""Deprecated TLS versions."""
|
||||
|
||||
UNACCEPTABLE_TLS_VERSIONS: List[str] = ["TLSv1", "TLSv1.0", "TLSv1.1"]
|
||||
"""Unacceptable TLS versions."""
|
||||
|
||||
MANDATORY_TLS_CIPHERSUITES: Dict[str, Dict[str, str]] = {
|
||||
"TLSv1.2": {
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256": "ECDHE-ECDSA-AES128-GCM-SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384": "ECDHE-ECDSA-AES256-GCM-SHA384",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256": "ECDHE-RSA-AES128-GCM-SHA256",
|
||||
},
|
||||
"TLSv1.3": {},
|
||||
}
|
||||
"""Access dictionary by TLS version that translates from cipher suites IANI (key)
|
||||
to OpenSSL name (value)."""
|
||||
|
||||
APPROVED_TLS_CIPHERSUITES: Dict[str, Dict[str, str]] = {
|
||||
"TLSv1.2": {
|
||||
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384": "ECDHE-RSA-AES256-GCM-SHA384",
|
||||
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256": "ECDHE-ECDSA-CHACHA20-POLY1305",
|
||||
"TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256": "ECDHE-RSA-CHACHA20-POLY1305",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_CCM": "ECDHE-ECDSA-AES256-CCM",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CCM": "ECDHE-ECDSA-AES128-CCM",
|
||||
},
|
||||
"TLSv1.3": {
|
||||
"TLS_AES_128_GCM_SHA256": "TLS_AES_128_GCM_SHA256",
|
||||
"TLS_AES_256_GCM_SHA384": "TLS_AES_256_GCM_SHA384",
|
||||
"TLS_CHACHA20_POLY1305_SHA256": "TLS_CHACHA20_POLY1305_SHA256",
|
||||
"TLS_AES_128_CCM_SHA256": "TLS_AES_128_CCM_SHA256",
|
||||
},
|
||||
}
|
||||
"""Access dictionary by TLS version that translates from cipher suites IANI (key)
|
||||
to OpenSSL name (value)."""
|
||||
|
||||
DEPRECATED_TLS_CIPHERSUITES: Dict[str, Dict[str, str]] = {
|
||||
"TLSv1.2": {
|
||||
"TLS_DHE_RSA_WITH_AES_128_GCM_SHA256": "DHE-RSA-AES128-GCM-SHA256",
|
||||
"TLS_DHE_RSA_WITH_AES_256_GCM_SHA384": "DHE-RSA-AES256-GCM-SHA384",
|
||||
"TLS_DHE_RSA_WITH_AES_256_CCM": "DHE-RSA-AES256-CCM",
|
||||
"TLS_DHE_RSA_WITH_AES_128_CCM": "DHE-RSA-AES128-CCM",
|
||||
"TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256": "DHE-RSA-CHACHA20-POLY1305",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8": "ECDHE-ECDSA-AES256-CCM8",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8": "ECDHE-ECDSA-AES128-CCM8",
|
||||
"TLS_DHE_RSA_WITH_AES_256_CCM_8": "DHE-RSA-AES256-CCM8",
|
||||
"TLS_DHE_RSA_WITH_AES_128_CCM_8": "DHE-RSA-AES128-CCM8",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256": "ECDHE-ECDSA-AES128-SHA256",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256": "ECDHE-RSA-AES128-SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384": "ECDHE-ECDSA-AES256-SHA384",
|
||||
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384": "ECDHE-RSA-AES256-SHA384",
|
||||
"TLS_DHE_DSS_WITH_AES_256_GCM_SHA384": "DHE-DSS-AES256-GCM-SHA384",
|
||||
"TLS_DHE_DSS_WITH_AES_128_GCM_SHA256": "DHE-DSS-AES128-GCM-SHA256",
|
||||
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA256": "DHE-DSS-AES128-SHA256",
|
||||
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA256": "DHE-DSS-AES256-SHA256",
|
||||
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA256": "DHE-RSA-AES256-SHA256",
|
||||
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA256": "DHE-RSA-AES128-SHA256",
|
||||
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256": "DHE-RSA-CAMELLIA256-SHA256",
|
||||
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256": "DHE-RSA-CAMELLIA128-SHA256",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA": "ECDHE-RSA-AES128-SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA": "ECDHE-ECDSA-AES128-SHA",
|
||||
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA": "ECDHE-RSA-AES256-SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA": "ECDHE-ECDSA-AES256-SHA",
|
||||
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA": "DHE-DSS-AES128-SHA",
|
||||
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA": "DHE-RSA-AES128-SHA",
|
||||
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA": "DHE-RSA-AES256-SHA",
|
||||
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA": "DHE-DSS-AES256-SHA",
|
||||
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA": "DHE-RSA-CAMELLIA256-SHA",
|
||||
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA": "DHE-RSA-CAMELLIA128-SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256": "ECDH-ECDSA-AES128-SHA256",
|
||||
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256": "ECDH-RSA-AES128-SHA256",
|
||||
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384": "ECDH-RSA-AES256-SHA384",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384": "ECDH-ECDSA-AES256-SHA384",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA": "ECDH-ECDSA-AES128-SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA": "ECDH-ECDSA-AES256-SHA",
|
||||
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA": "ECDH-RSA-AES128-SHA",
|
||||
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA": "ECDH-RSA-AES256-SHA",
|
||||
"TLS_RSA_WITH_AES_128_GCM_SHA256": "AES128-GCM-SHA256",
|
||||
"TLS_RSA_WITH_AES_128_CCM": "AES128-CCM",
|
||||
"TLS_RSA_WITH_AES_128_CCM_8": "AES128-CCM8",
|
||||
"TLS_RSA_WITH_AES_256_GCM_SHA384": "AES256-GCM-SHA384",
|
||||
"TLS_RSA_WITH_AES_256_CCM": "AES256-CCM",
|
||||
"TLS_RSA_WITH_AES_256_CCM_8": "AES256-CCM8",
|
||||
"TLS_RSA_WITH_AES_128_CBC_SHA256": "AES128-SHA256",
|
||||
"TLS_RSA_WITH_AES_256_CBC_SHA256": "AES256-SHA256",
|
||||
"TLS_RSA_WITH_AES_128_CBC_SHA": "AES128-SHA",
|
||||
"TLS_RSA_WITH_AES_256_CBC_SHA": "AES256-SHA",
|
||||
"TLS_RSA_WITH_CAMELLIA_256_CBC_SHA": "CAMELLIA256-SHA",
|
||||
"TLS_RSA_WITH_CAMELLIA_128_CBC_SHA": "CAMELLIA128-SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256": "ECDH-ECDSA-AES128-GCM-SHA256",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384": "ECDH-ECDSA-AES256-GCM-SHA384",
|
||||
"TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256": "ECDH-RSA-AES128-GCM-SHA256",
|
||||
"TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384": "ECDH-RSA-AES256-GCM-SHA384",
|
||||
},
|
||||
"TLSv1.3": {"TLS_AES_128_CCM_8_SHA256": "TLS_AES_128_CCM_8_SHA256"},
|
||||
}
|
||||
"""Access dictionary by TLS version that translates from cipher suites IANI (key)
|
||||
to OpenSSL name (value)."""
|
||||
|
||||
UNACCEPTABLE_TLS_CIPHERSUITES: Dict[str, Dict[str, str]] = {
|
||||
"TLSv1.2": {
|
||||
"TLS_DH_RSA_WITH_AES_128_CBC_SHA256": "DH-RSA-AES128-SHA256",
|
||||
"TLS_DH_RSA_WITH_AES_256_CBC_SHA256": "DH-RSA-AES256-SHA256",
|
||||
"TLS_DH_DSS_WITH_AES_128_CBC_SHA256": "DH-DSS-AES128-SHA256",
|
||||
"TLS_DH_DSS_WITH_AES_128_CBC_SHA": "DH-DSS-AES128-SHA",
|
||||
"TLS_DH_DSS_WITH_AES_256_CBC_SHA": "DH-DSS-AES256-SHA",
|
||||
"TLS_DH_DSS_WITH_AES_256_CBC_SHA256": "DH-DSS-AES256-SHA256",
|
||||
"TLS_DH_RSA_WITH_AES_128_CBC_SHA": "DH-RSA-AES128-SHA",
|
||||
"TLS_DH_RSA_WITH_AES_256_CBC_SHA": "DH-RSA-AES256-SHA",
|
||||
"TLS_DH_DSS_WITH_AES_128_GCM_SHA256": "DH-DSS-AES128-GCM-SHA256",
|
||||
"TLS_DH_DSS_WITH_AES_256_GCM_SHA384": "DH-DSS-AES256-GCM-SHA384",
|
||||
"TLS_DH_RSA_WITH_AES_128_GCM_SHA256": "DH-RSA-AES128-GCM-SHA256",
|
||||
"TLS_DH_RSA_WITH_AES_256_GCM_SHA384": "DH-RSA-AES256-GCM-SHA384",
|
||||
"TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA": "DH-DSS-DES-CBC3-SHA",
|
||||
"TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA": "DH-RSA-DES-CBC3-SHA",
|
||||
"TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA": "EDH-DSS-DES-CBC3-SHA",
|
||||
"TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA": "EDH-RSA-DES-CBC3-SHA",
|
||||
"TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA": "ECDH-RSA-DES-CBC3-SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA": "ECDH-ECDSA-DES-CBC3-SHA",
|
||||
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA": "ECDHE-RSA-DES-CBC3-SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA": "ECDHE-ECDSA-DES-CBC3-SHA",
|
||||
"TLS_RSA_WITH_3DES_EDE_CBC_SHA": "DES-CBC3-SHA",
|
||||
"TLS_KRB5_WITH_3DES_EDE_CBC_SHA": "KRB5-DES-CBC3-SHA",
|
||||
"TLS_KRB5_WITH_3DES_EDE_CBC_MD5": "KRB5-DES-CBC3-MD5",
|
||||
"TLS_KRB5_WITH_IDEA_CBC_SHA": "KRB5-IDEA-CBC-SHA",
|
||||
},
|
||||
"TLSv1.3": {},
|
||||
}
|
||||
"""Access dictionary by TLS version that translates from cipher suites IANI (key)
|
||||
to OpenSSL name (value)."""
|
||||
@@ -0,0 +1,214 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""
|
||||
Type hint aliases hub.
|
||||
"""
|
||||
import os
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from time import struct_time
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Deque,
|
||||
Dict,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
TypedDict,
|
||||
Union,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .custom_types import HexLiteral
|
||||
|
||||
|
||||
class MySQLScriptPartition(TypedDict):
|
||||
"""Represents a partition or sub-script."""
|
||||
|
||||
mappable_stmt: bytes
|
||||
single_stmts: Deque[bytes]
|
||||
|
||||
|
||||
StrOrBytes = Union[str, bytes]
|
||||
"""Shortcut to for `String or Bytes`."""
|
||||
|
||||
StrOrBytesAny = Union[StrOrBytes, Any]
|
||||
"""Shortcut to for `String or Bytes or Any`."""
|
||||
|
||||
StrOrBytesPath = Union[StrOrBytes, os.PathLike]
|
||||
"""Shortcut to for `String or Bytes or os.PathLike` - this shortcut
|
||||
may come in handy as a hint for path-like variables."""
|
||||
|
||||
PythonProducedType = Union[
|
||||
Decimal,
|
||||
bytes,
|
||||
date,
|
||||
datetime,
|
||||
float,
|
||||
int,
|
||||
Set[str],
|
||||
str,
|
||||
timedelta,
|
||||
None,
|
||||
]
|
||||
"""
|
||||
Python producible types in converter - Types produced after processing a MySQL text
|
||||
result using the built-in converter.
|
||||
"""
|
||||
|
||||
BinaryProtocolType = Union[
|
||||
Decimal,
|
||||
bytes,
|
||||
date,
|
||||
datetime,
|
||||
float,
|
||||
int,
|
||||
str,
|
||||
time,
|
||||
timedelta,
|
||||
None,
|
||||
]
|
||||
"""
|
||||
Supported MySQL Binary Protocol Types - Python type that can be
|
||||
converted to a MySQL type. It's a subset of `MySQLConvertibleType`.
|
||||
"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
MySQLConvertibleType = Union[BinaryProtocolType, Enum, bool, struct_time]
|
||||
"""
|
||||
MySQL convertible Python types - Python types consumed by the built-in converter that
|
||||
can be converted to MySQL. It's a superset of `BinaryProtocolType`.
|
||||
"""
|
||||
|
||||
MySQLProducedType = Optional[Union[int, float, bytes, "HexLiteral"]]
|
||||
"""
|
||||
Types produced after processing MySQL convertible Python types.
|
||||
"""
|
||||
|
||||
HandShakeType = Dict[str, Optional[Union[int, str, bytes]]]
|
||||
"""Dictionary representing the parsed `handshake response`
|
||||
sent at `connection` time by the server."""
|
||||
|
||||
OkPacketType = Dict[str, Optional[Union[int, str]]]
|
||||
"""Dictionary representing the parsed `OK response`
|
||||
produced by the server to signal successful completion of a command."""
|
||||
|
||||
EofPacketType = OkPacketType
|
||||
"""Dictionary representing the parsed `EOF response`
|
||||
produced by the server to signal successful completion of a command.
|
||||
In the MySQL client/server protocol, the EOF and OK responses serve
|
||||
the same purpose, to mark the end of a query execution resul.
|
||||
"""
|
||||
|
||||
DescriptionType = Tuple[
|
||||
str, # field name
|
||||
int, # field type
|
||||
None, # you can ignore it or take a look at protocol.parse_column()
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Union[bool, int], # null ok
|
||||
int, # field flags
|
||||
int, # MySQL charset ID
|
||||
]
|
||||
"""
|
||||
Tuple representing column information.
|
||||
|
||||
Sometimes it can be represented as a 2-Tuple of the form:
|
||||
`Tuple[str, int]` <-> field name, field type.
|
||||
|
||||
However, let's stick with the 9-Tuple format produced by the protocol module.
|
||||
```
|
||||
DescriptionType = Tuple[
|
||||
str, # field name
|
||||
int, # field type
|
||||
None, # you can ignore it or take a look at protocol.parse_column()
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Union[bool, int], # null ok
|
||||
int, # field flags
|
||||
int, # MySQL charset ID
|
||||
]
|
||||
```
|
||||
"""
|
||||
|
||||
StatsPacketType = Dict[str, Union[int, Decimal]]
|
||||
"""Dictionary representing the parsed `Stats response`
|
||||
produced by the server after completing a `COM_STATISTICS` command."""
|
||||
|
||||
ResultType = Mapping[
|
||||
str, Optional[Union[int, str, EofPacketType, List[DescriptionType]]]
|
||||
]
|
||||
"""
|
||||
Represents the returned type by `MySQLConnection._handle_result()`.
|
||||
|
||||
This method can return a dictionary of the form:
|
||||
- columns -> column information
|
||||
- EOF_response -> end-of-file response
|
||||
|
||||
Or, it can return an `OkPacketType`/`EofPacketType`.
|
||||
"""
|
||||
|
||||
RowItemType = Union[PythonProducedType, BinaryProtocolType]
|
||||
"""Item type found in `RowType`."""
|
||||
|
||||
RowType = Tuple[RowItemType, ...]
|
||||
"""Row returned by the MySQL server after sending a query command."""
|
||||
|
||||
CextEofPacketType = Dict[str, int]
|
||||
"""Similar to `EofPacketType` but for the C-EXT."""
|
||||
|
||||
CextResultType = Dict[str, Union[CextEofPacketType, List[DescriptionType]]]
|
||||
"""Similar to `ResultType` but for the C-EXT.
|
||||
|
||||
Represents the returned type by `CMySQLConnection.fetch_eof_columns()`.
|
||||
|
||||
This method returns a dictionary of the form:
|
||||
- columns -> column information
|
||||
- EOF_response -> end-of-file response
|
||||
"""
|
||||
|
||||
ParamsSequenceType = Sequence[MySQLConvertibleType]
|
||||
"""Sequence type expected by `cursor.execute()`."""
|
||||
|
||||
ParamsDictType = Dict[str, MySQLConvertibleType]
|
||||
"""Dictionary type expected by `cursor.execute()`."""
|
||||
|
||||
ParamsSequenceOrDictType = Union[ParamsDictType, ParamsSequenceType]
|
||||
"""Shortcut for `ParamsSequenceType or ParamsDictType`."""
|
||||
|
||||
WarningType = Tuple[str, int, str]
|
||||
"""Warning generated by the previously executed operation."""
|
||||
@@ -0,0 +1,760 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Utilities."""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import platform
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
import warnings
|
||||
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from stringprep import (
|
||||
in_table_a1,
|
||||
in_table_b1,
|
||||
in_table_c3,
|
||||
in_table_c4,
|
||||
in_table_c5,
|
||||
in_table_c6,
|
||||
in_table_c7,
|
||||
in_table_c8,
|
||||
in_table_c9,
|
||||
in_table_c11,
|
||||
in_table_c12,
|
||||
in_table_c21_c22,
|
||||
in_table_d1,
|
||||
in_table_d2,
|
||||
)
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
Union,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
|
||||
from .custom_types import HexLiteral
|
||||
from .tls_ciphers import DEPRECATED_TLS_CIPHERSUITES, DEPRECATED_TLS_VERSIONS
|
||||
from .types import StrOrBytes
|
||||
|
||||
__MYSQL_DEBUG__: bool = False
|
||||
|
||||
NUMERIC_TYPES: Tuple[Type[int], Type[float], Type[Decimal], Type[HexLiteral]] = (
|
||||
int,
|
||||
float,
|
||||
Decimal,
|
||||
HexLiteral,
|
||||
)
|
||||
|
||||
|
||||
def intread(buf: Union[int, bytes]) -> int:
|
||||
"""Unpacks the given buffer to an integer"""
|
||||
if isinstance(buf, int):
|
||||
return buf
|
||||
length, tmp = len(buf), bytearray()
|
||||
if length == 1:
|
||||
return buf[0]
|
||||
if length <= 4:
|
||||
tmp += buf + b"\x00" * (4 - length)
|
||||
return int(struct.unpack("<I", tmp)[0])
|
||||
tmp += buf + b"\x00" * (8 - length)
|
||||
return int(struct.unpack("<Q", tmp)[0])
|
||||
|
||||
|
||||
def int1store(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned byte (1 byte) and packs it as a bytes-object.
|
||||
|
||||
Returns string.
|
||||
"""
|
||||
if i < 0 or i > 255:
|
||||
raise ValueError("int1store requires 0 <= i <= 255")
|
||||
return struct.pack("<B", i)
|
||||
|
||||
|
||||
def int2store(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned short (2 bytes) and packs it as a bytes-object.
|
||||
|
||||
Returns string.
|
||||
"""
|
||||
if i < 0 or i > 65535:
|
||||
raise ValueError("int2store requires 0 <= i <= 65535")
|
||||
return struct.pack("<H", i)
|
||||
|
||||
|
||||
def int3store(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned integer (3 bytes) and packs it as a bytes-object.
|
||||
|
||||
Returns string.
|
||||
"""
|
||||
if i < 0 or i > 16777215:
|
||||
raise ValueError("int3store requires 0 <= i <= 16777215")
|
||||
return struct.pack("<I", i)[0:3]
|
||||
|
||||
|
||||
def int4store(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned integer (4 bytes) and packs it as a bytes-object.
|
||||
|
||||
Returns string.
|
||||
"""
|
||||
if i < 0 or i > 4294967295:
|
||||
raise ValueError("int4store requires 0 <= i <= 4294967295")
|
||||
return struct.pack("<I", i)
|
||||
|
||||
|
||||
def int8store(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned integer (8 bytes) and packs it as string.
|
||||
|
||||
Returns string.
|
||||
"""
|
||||
if i < 0 or i > 18446744073709551616:
|
||||
raise ValueError("int8store requires 0 <= i <= 2^64")
|
||||
return struct.pack("<Q", i)
|
||||
|
||||
|
||||
def intstore(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned integers and packs it as a bytes-object.
|
||||
|
||||
This function uses int1store, int2store, int3store,
|
||||
int4store or int8store depending on the integer value.
|
||||
|
||||
returns string.
|
||||
"""
|
||||
if i < 0 or i > 18446744073709551616:
|
||||
raise ValueError("intstore requires 0 <= i <= 2^64")
|
||||
|
||||
if i <= 255:
|
||||
formed_string = int1store
|
||||
elif i <= 65535:
|
||||
formed_string = int2store
|
||||
elif i <= 16777215:
|
||||
formed_string = int3store
|
||||
elif i <= 4294967295:
|
||||
formed_string = int4store
|
||||
else:
|
||||
formed_string = int8store
|
||||
|
||||
return formed_string(i)
|
||||
|
||||
|
||||
def lc_int(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned integer and packs it as bytes,
|
||||
with the information of how much bytes the encoded int takes.
|
||||
"""
|
||||
if i < 0 or i > 18446744073709551616:
|
||||
raise ValueError("Requires 0 <= i <= 2^64")
|
||||
|
||||
if i < 251:
|
||||
return struct.pack("<B", i)
|
||||
if i <= 65535:
|
||||
return b"\xfc" + struct.pack("<H", i)
|
||||
if i <= 16777215:
|
||||
return b"\xfd" + struct.pack("<I", i)[0:3]
|
||||
|
||||
return b"\xfe" + struct.pack("<Q", i)
|
||||
|
||||
|
||||
def read_bytes(buf: bytes, size: int) -> Tuple[bytes, bytes]:
|
||||
"""
|
||||
Reads bytes from a buffer.
|
||||
|
||||
Returns a tuple with buffer less the read bytes, and the bytes.
|
||||
"""
|
||||
res = buf[0:size]
|
||||
return (buf[size:], res)
|
||||
|
||||
|
||||
def read_lc_string(buf: bytes) -> Tuple[bytes, Optional[bytes]]:
|
||||
"""
|
||||
Takes a buffer and reads a length coded string from the start.
|
||||
|
||||
This is how Length coded strings work
|
||||
|
||||
If the string is 250 bytes long or smaller, then it looks like this:
|
||||
|
||||
<-- 1b -->
|
||||
+----------+-------------------------
|
||||
| length | a string goes here
|
||||
+----------+-------------------------
|
||||
|
||||
If the string is bigger than 250, then it looks like this:
|
||||
|
||||
<- 1b -><- 2/3/8 ->
|
||||
+------+-----------+-------------------------
|
||||
| type | length | a string goes here
|
||||
+------+-----------+-------------------------
|
||||
|
||||
if type == \xfc:
|
||||
length is code in next 2 bytes
|
||||
elif type == \xfd:
|
||||
length is code in next 3 bytes
|
||||
elif type == \xfe:
|
||||
length is code in next 8 bytes
|
||||
|
||||
NULL has a special value. If the buffer starts with \xfb then
|
||||
it's a NULL and we return None as value.
|
||||
|
||||
Returns a tuple (trucated buffer, bytes).
|
||||
"""
|
||||
if buf[0] == 251: # \xfb
|
||||
# NULL value
|
||||
return (buf[1:], None)
|
||||
|
||||
length = lsize = 0
|
||||
fst = buf[0]
|
||||
|
||||
if fst <= 250: # \xFA
|
||||
length = fst
|
||||
return (buf[1 + length :], buf[1 : length + 1])
|
||||
if fst == 252:
|
||||
lsize = 2
|
||||
elif fst == 253:
|
||||
lsize = 3
|
||||
elif fst == 254:
|
||||
lsize = 8
|
||||
|
||||
length = intread(buf[1 : lsize + 1])
|
||||
return (buf[lsize + length + 1 :], buf[lsize + 1 : length + lsize + 1])
|
||||
|
||||
|
||||
def read_lc_string_list(buf: bytes) -> Optional[Tuple[Optional[bytes], ...]]:
|
||||
"""Reads all length encoded strings from the given buffer
|
||||
|
||||
Returns a list of bytes
|
||||
"""
|
||||
byteslst: List[Optional[bytes]] = []
|
||||
|
||||
sizes = {252: 2, 253: 3, 254: 8}
|
||||
|
||||
buf_len = len(buf)
|
||||
pos = 0
|
||||
|
||||
while pos < buf_len:
|
||||
first = buf[pos]
|
||||
if first == 255:
|
||||
# Special case when MySQL error 1317 is returned by MySQL.
|
||||
# We simply return None.
|
||||
return None
|
||||
if first == 251:
|
||||
# NULL value
|
||||
byteslst.append(None)
|
||||
pos += 1
|
||||
else:
|
||||
if first <= 250:
|
||||
length = first
|
||||
byteslst.append(buf[(pos + 1) : length + (pos + 1)])
|
||||
pos += 1 + length
|
||||
else:
|
||||
lsize = 0
|
||||
try:
|
||||
lsize = sizes[first]
|
||||
except KeyError:
|
||||
return None
|
||||
length = intread(buf[(pos + 1) : lsize + (pos + 1)])
|
||||
byteslst.append(buf[pos + 1 + lsize : length + lsize + (pos + 1)])
|
||||
pos += 1 + lsize + length
|
||||
|
||||
return tuple(byteslst)
|
||||
|
||||
|
||||
def read_string(
|
||||
buf: bytes,
|
||||
end: Optional[bytes] = None,
|
||||
size: Optional[int] = None,
|
||||
) -> Tuple[bytes, bytes]:
|
||||
"""
|
||||
Reads a string up until a character or for a given size.
|
||||
|
||||
Returns a tuple (trucated buffer, string).
|
||||
"""
|
||||
if end is None and size is None:
|
||||
raise ValueError("read_string() needs either end or size")
|
||||
|
||||
if end is not None:
|
||||
try:
|
||||
idx = buf.index(end)
|
||||
except ValueError as err:
|
||||
raise ValueError("end byte not present in buffer") from err
|
||||
return (buf[idx + 1 :], buf[0:idx])
|
||||
if size is not None:
|
||||
return read_bytes(buf, size)
|
||||
|
||||
raise ValueError("read_string() needs either end or size (weird)")
|
||||
|
||||
|
||||
def read_int(buf: bytes, size: int) -> Tuple[bytes, int]:
|
||||
"""Read an integer from buffer
|
||||
|
||||
Returns a tuple (truncated buffer, int)
|
||||
"""
|
||||
res = intread(buf[0:size])
|
||||
return (buf[size:], res)
|
||||
|
||||
|
||||
def read_lc_int(buf: bytes) -> Tuple[bytes, Optional[int]]:
|
||||
"""
|
||||
Takes a buffer and reads an length code string from the start.
|
||||
|
||||
Returns a tuple with buffer less the integer and the integer read.
|
||||
"""
|
||||
if not buf:
|
||||
raise ValueError("Empty buffer.")
|
||||
|
||||
lcbyte = buf[0]
|
||||
if lcbyte == 251:
|
||||
return (buf[1:], None)
|
||||
if lcbyte < 251:
|
||||
return (buf[1:], int(lcbyte))
|
||||
if lcbyte == 252:
|
||||
return (buf[3:], struct.unpack("<xH", buf[0:3])[0])
|
||||
if lcbyte == 253:
|
||||
return (buf[4:], struct.unpack("<I", buf[1:4] + b"\x00")[0])
|
||||
if lcbyte == 254:
|
||||
return (buf[9:], struct.unpack("<xQ", buf[0:9])[0])
|
||||
raise ValueError("Failed reading length encoded integer")
|
||||
|
||||
|
||||
#
|
||||
# For debugging
|
||||
#
|
||||
def _digest_buffer(buf: StrOrBytes) -> str:
|
||||
"""Debug function for showing buffers"""
|
||||
if not isinstance(buf, str):
|
||||
return "".join([f"\\x{c:02x}" for c in buf])
|
||||
return "".join([f"\\x{ord(c):02x}" for c in buf])
|
||||
|
||||
|
||||
def print_buffer(
|
||||
abuffer: StrOrBytes, prefix: Optional[str] = None, limit: int = 30
|
||||
) -> None:
|
||||
"""Debug function printing output of _digest_buffer()"""
|
||||
if prefix:
|
||||
if limit and limit > 0:
|
||||
digest = _digest_buffer(abuffer[0:limit])
|
||||
else:
|
||||
digest = _digest_buffer(abuffer)
|
||||
print(prefix + ": " + digest)
|
||||
else:
|
||||
print(_digest_buffer(abuffer))
|
||||
|
||||
|
||||
def _parse_os_release() -> Dict[str, str]:
|
||||
"""Parse the contents of /etc/os-release file.
|
||||
|
||||
Returns:
|
||||
A dictionary containing release information.
|
||||
"""
|
||||
distro: Dict[str, str] = {}
|
||||
os_release_file = os.path.join("/etc", "os-release")
|
||||
if not os.path.exists(os_release_file):
|
||||
return distro
|
||||
with open(os_release_file, encoding="utf-8") as file_obj:
|
||||
for line in file_obj:
|
||||
key_value = line.split("=")
|
||||
if len(key_value) != 2:
|
||||
continue
|
||||
key = key_value[0].lower()
|
||||
value = key_value[1].rstrip("\n").strip('"')
|
||||
distro[key] = value
|
||||
return distro
|
||||
|
||||
|
||||
def _parse_lsb_release() -> Dict[str, str]:
|
||||
"""Parse the contents of /etc/lsb-release file.
|
||||
|
||||
Returns:
|
||||
A dictionary containing release information.
|
||||
"""
|
||||
distro = {}
|
||||
lsb_release_file = os.path.join("/etc", "lsb-release")
|
||||
if os.path.exists(lsb_release_file):
|
||||
with open(lsb_release_file, encoding="utf-8") as file_obj:
|
||||
for line in file_obj:
|
||||
key_value = line.split("=")
|
||||
if len(key_value) != 2:
|
||||
continue
|
||||
key = key_value[0].lower()
|
||||
value = key_value[1].rstrip("\n").strip('"')
|
||||
distro[key] = value
|
||||
return distro
|
||||
|
||||
|
||||
def _parse_lsb_release_command() -> Optional[Dict[str, str]]:
|
||||
"""Parse the output of the lsb_release command.
|
||||
|
||||
Returns:
|
||||
A dictionary containing release information.
|
||||
"""
|
||||
distro = {}
|
||||
with open(os.devnull, "w", encoding="utf-8") as devnull:
|
||||
try:
|
||||
stdout = subprocess.check_output(("lsb_release", "-a"), stderr=devnull)
|
||||
except OSError:
|
||||
return None
|
||||
lines = stdout.decode(sys.getfilesystemencoding()).splitlines()
|
||||
for line in lines:
|
||||
key_value = line.split(":")
|
||||
if len(key_value) != 2:
|
||||
continue
|
||||
key = key_value[0].replace(" ", "_").lower()
|
||||
value = key_value[1].strip("\t")
|
||||
distro[key] = value
|
||||
return distro
|
||||
|
||||
|
||||
def linux_distribution() -> Tuple[str, str, str]:
|
||||
"""Tries to determine the name of the Linux OS distribution name.
|
||||
|
||||
First tries to get information from ``/etc/os-release`` file.
|
||||
If fails, tries to get the information of ``/etc/lsb-release`` file.
|
||||
And finally the information of ``lsb-release`` command.
|
||||
|
||||
Returns:
|
||||
A tuple with (`name`, `version`, `codename`)
|
||||
"""
|
||||
distro: Optional[Dict[str, str]] = _parse_lsb_release()
|
||||
if distro:
|
||||
return (
|
||||
distro.get("distrib_id", ""),
|
||||
distro.get("distrib_release", ""),
|
||||
distro.get("distrib_codename", ""),
|
||||
)
|
||||
|
||||
distro = _parse_lsb_release_command()
|
||||
if distro:
|
||||
return (
|
||||
distro.get("distributor_id", ""),
|
||||
distro.get("release", ""),
|
||||
distro.get("codename", ""),
|
||||
)
|
||||
|
||||
distro = _parse_os_release()
|
||||
if distro:
|
||||
return (
|
||||
distro.get("name", ""),
|
||||
distro.get("version_id", ""),
|
||||
distro.get("version_codename", ""),
|
||||
)
|
||||
|
||||
return ("", "", "")
|
||||
|
||||
|
||||
def _get_unicode_read_direction(unicode_str: str) -> str:
|
||||
"""Get the readiness direction of the unicode string.
|
||||
|
||||
We assume that the direction is "L-to-R" if the first character does not
|
||||
indicate the direction is "R-to-L" or an "AL" (Arabic Letter).
|
||||
"""
|
||||
if unicode_str and unicodedata.bidirectional(unicode_str[0]) in (
|
||||
"R",
|
||||
"AL",
|
||||
):
|
||||
return "R-to-L"
|
||||
return "L-to-R"
|
||||
|
||||
|
||||
def _get_unicode_direction_rule(unicode_str: str) -> Dict[str, Callable[[str], bool]]:
|
||||
"""
|
||||
1) The characters in section 5.8 MUST be prohibited.
|
||||
|
||||
2) If a string contains any RandALCat character, the string MUST NOT
|
||||
contain any LCat character.
|
||||
|
||||
3) If a string contains any RandALCat character, a RandALCat
|
||||
character MUST be the first character of the string, and a
|
||||
RandALCat character MUST be the last character of the string.
|
||||
"""
|
||||
read_dir = _get_unicode_read_direction(unicode_str)
|
||||
|
||||
# point 3)
|
||||
if read_dir == "R-to-L":
|
||||
if not (in_table_d1(unicode_str[0]) and in_table_d1(unicode_str[-1])):
|
||||
raise ValueError(
|
||||
"Invalid unicode Bidirectional sequence, if the "
|
||||
"first character is RandALCat, the final character"
|
||||
"must be RandALCat too."
|
||||
)
|
||||
# characters from in_table_d2 are prohibited.
|
||||
return {"Bidirectional Characters requirement 2 [StringPrep, d2]": in_table_d2}
|
||||
|
||||
# characters from in_table_d1 are prohibited.
|
||||
return {"Bidirectional Characters requirement 2 [StringPrep, d2]": in_table_d1}
|
||||
|
||||
|
||||
def validate_normalized_unicode_string(
|
||||
normalized_str: str,
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""Check for Prohibited Output according to rfc4013 profile.
|
||||
|
||||
This profile specifies the following characters as prohibited input:
|
||||
|
||||
- Non-ASCII space characters [StringPrep, C.1.2]
|
||||
- ASCII control characters [StringPrep, C.2.1]
|
||||
- Non-ASCII control characters [StringPrep, C.2.2]
|
||||
- Private Use characters [StringPrep, C.3]
|
||||
- Non-character code points [StringPrep, C.4]
|
||||
- Surrogate code points [StringPrep, C.5]
|
||||
- Inappropriate for plain text characters [StringPrep, C.6]
|
||||
- Inappropriate for canonical representation characters [StringPrep, C.7]
|
||||
- Change display properties or deprecated characters [StringPrep, C.8]
|
||||
- Tagging characters [StringPrep, C.9]
|
||||
|
||||
In addition of checking of Bidirectional Characters [StringPrep, Section 6]
|
||||
and the Unassigned Code Points [StringPrep, A.1].
|
||||
|
||||
Returns:
|
||||
A tuple with ("probited character", "breaked_rule")
|
||||
"""
|
||||
rules = {
|
||||
"Space characters that contains the ASCII code points": in_table_c11,
|
||||
"Space characters non-ASCII code points": in_table_c12,
|
||||
"Unassigned Code Points [StringPrep, A.1]": in_table_a1,
|
||||
"Non-ASCII space characters [StringPrep, C.1.2]": in_table_c12,
|
||||
"ASCII control characters [StringPrep, C.2.1]": in_table_c21_c22,
|
||||
"Private Use characters [StringPrep, C.3]": in_table_c3,
|
||||
"Non-character code points [StringPrep, C.4]": in_table_c4,
|
||||
"Surrogate code points [StringPrep, C.5]": in_table_c5,
|
||||
"Inappropriate for plain text characters [StringPrep, C.6]": in_table_c6,
|
||||
"Inappropriate for canonical representation characters [StringPrep, C.7]": in_table_c7,
|
||||
"Change display properties or deprecated characters [StringPrep, C.8]": in_table_c8,
|
||||
"Tagging characters [StringPrep, C.9]": in_table_c9,
|
||||
}
|
||||
|
||||
try:
|
||||
rules.update(_get_unicode_direction_rule(normalized_str))
|
||||
except ValueError as err:
|
||||
return normalized_str, str(err)
|
||||
|
||||
for char in normalized_str:
|
||||
for rule, func in rules.items():
|
||||
if func(char) and char != " ":
|
||||
return char, rule
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def normalize_unicode_string(a_string: str) -> str:
|
||||
"""normalizes a unicode string according to rfc4013
|
||||
|
||||
Normalization of a unicode string according to rfc4013: The SASLprep profile
|
||||
of the "stringprep" algorithm.
|
||||
|
||||
Normalization Unicode equivalence is the specification by the Unicode
|
||||
character encoding standard that some sequences of code points represent
|
||||
essentially the same character.
|
||||
|
||||
This method normalizes using the Normalization Form Compatibility
|
||||
Composition (NFKC), as described in rfc4013 2.2.
|
||||
|
||||
Returns:
|
||||
Normalized unicode string according to rfc4013.
|
||||
"""
|
||||
# Per rfc4013 2.1. Mapping
|
||||
# non-ASCII space characters [StringPrep, C.1.2] are mapped to ' ' (U+0020)
|
||||
# "commonly mapped to nothing" characters [StringPrep, B.1] are mapped to ''
|
||||
nstr_list = [
|
||||
" " if in_table_c12(char) else "" if in_table_b1(char) else char
|
||||
for char in a_string
|
||||
]
|
||||
|
||||
nstr = "".join(nstr_list)
|
||||
|
||||
# Per rfc4013 2.2. Use NFKC Normalization Form Compatibility Composition
|
||||
# Characters are decomposed by compatibility, then recomposed by canonical
|
||||
# equivalence.
|
||||
nstr = unicodedata.normalize("NFKC", nstr)
|
||||
if not nstr:
|
||||
# Normilization results in empty string.
|
||||
return ""
|
||||
|
||||
return nstr
|
||||
|
||||
|
||||
def init_bytearray(
|
||||
payload: Union[int, StrOrBytes] = b"", encoding: str = "utf-8"
|
||||
) -> bytearray:
|
||||
"""Initialize a bytearray from the payload."""
|
||||
if isinstance(payload, bytearray):
|
||||
return payload
|
||||
if isinstance(payload, int):
|
||||
return bytearray(payload)
|
||||
if not isinstance(payload, bytes):
|
||||
try:
|
||||
return bytearray(payload.encode(encoding=encoding))
|
||||
except AttributeError as err:
|
||||
raise ValueError("payload must be a str or bytes") from err
|
||||
|
||||
return bytearray(payload)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_platform() -> Dict[str, Union[str, Tuple[str, str]]]:
|
||||
"""Return a dict with the platform arch and OS version."""
|
||||
plat: Dict[str, Union[str, Tuple[str, str]]] = {"arch": "", "version": ""}
|
||||
if os.name == "nt":
|
||||
if "64" in platform.architecture()[0]:
|
||||
plat["arch"] = "x86_64"
|
||||
elif "32" in platform.architecture()[0]:
|
||||
plat["arch"] = "i386"
|
||||
else:
|
||||
plat["arch"] = platform.architecture()
|
||||
plat["version"] = f"Windows-{platform.win32_ver()[1]}"
|
||||
else:
|
||||
plat["arch"] = platform.machine()
|
||||
if platform.system() == "Darwin":
|
||||
plat["version"] = f"macOS-{platform.mac_ver()[0]}"
|
||||
else:
|
||||
plat["version"] = "-".join(linux_distribution()[0:2])
|
||||
|
||||
return plat
|
||||
|
||||
|
||||
def import_object(fullpath: str) -> Any:
|
||||
"""Import an object from a fully qualified module path.
|
||||
|
||||
Args:
|
||||
obj (str): A string representing the fully qualified name of the object.
|
||||
|
||||
Returns:
|
||||
Object: The imported object.
|
||||
|
||||
Raises:
|
||||
ValueError: If the object can't be imported.
|
||||
|
||||
.. versionadded:: 8.0.33
|
||||
"""
|
||||
if not isinstance(fullpath, str):
|
||||
raise ValueError(
|
||||
"'fullpath' should be a str representing the fully qualified name of the "
|
||||
"object to be imported"
|
||||
)
|
||||
try:
|
||||
module_str, callable_str = fullpath.rsplit(".", 1)
|
||||
module = importlib.import_module(module_str)
|
||||
obj = getattr(module, callable_str)
|
||||
except ValueError:
|
||||
raise ValueError(f"No callable named '{fullpath}'") from None
|
||||
except (AttributeError, ModuleNotFoundError) as err:
|
||||
raise ValueError(f"{err}") from err
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def warn_ciphersuites_deprecated(cipher_as_ossl: str, tls_version: str) -> None:
|
||||
"""Emits a warning if a deprecated cipher is being utilized.
|
||||
|
||||
Args:
|
||||
cipher: Must be ingested as OpenSSL name.
|
||||
tls_versions: TLS version to check the cipher against.
|
||||
|
||||
Raises:
|
||||
DeprecationWarning: If the cipher is flagged as deprecated
|
||||
according to the OSSA cipher list.
|
||||
"""
|
||||
if cipher_as_ossl in DEPRECATED_TLS_CIPHERSUITES.get(tls_version, {}).values():
|
||||
warn_msg = (
|
||||
f"This connection is using TLS cipher {cipher_as_ossl} which is now "
|
||||
"deprecated and will be removed in a future release of "
|
||||
"MySQL Connector/Python."
|
||||
)
|
||||
warnings.warn(warn_msg, DeprecationWarning)
|
||||
|
||||
|
||||
def warn_tls_version_deprecated(tls_version: str) -> None:
|
||||
"""Emits a warning if a deprecated TLS version is being utilized.
|
||||
|
||||
Args:
|
||||
tls_versions: TLS version to check.
|
||||
|
||||
Raises:
|
||||
DeprecationWarning: If the TLS version is flagged as deprecated
|
||||
according to the OSSA cipher list.
|
||||
"""
|
||||
if tls_version in DEPRECATED_TLS_VERSIONS:
|
||||
warn_msg = (
|
||||
f"This connection is using TLS version {tls_version} which is now "
|
||||
"deprecated and will be removed in a future release of "
|
||||
"MySQL Connector/Python."
|
||||
)
|
||||
warnings.warn(warn_msg, DeprecationWarning)
|
||||
|
||||
|
||||
class GenericWrapper:
|
||||
"""Base class that provides basic object wrapper functionality."""
|
||||
|
||||
def __init__(self, wrapped: Any) -> None:
|
||||
"""Constructor."""
|
||||
self._wrapped: Any = wrapped
|
||||
|
||||
def __getattr__(self, attr: str) -> Any:
|
||||
"""Gets an attribute.
|
||||
|
||||
Attributes defined in the wrapper object have higher precedence
|
||||
than those wrapped object equivalent. Attributes not found in
|
||||
the wrapper are then searched in the wrapped object.
|
||||
"""
|
||||
if attr in self.__dict__:
|
||||
# this object has it
|
||||
return getattr(self, attr)
|
||||
# proxy to the wrapped object
|
||||
return getattr(self._wrapped, attr)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
"""Sets an attribute."""
|
||||
if "_wrapped" not in self.__dict__:
|
||||
self.__dict__["_wrapped"] = value
|
||||
return
|
||||
|
||||
if name in self.__dict__:
|
||||
# this object has it
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
# proxy to the wrapped object
|
||||
self._wrapped.__setattr__(name, value)
|
||||
|
||||
def get_wrapped_class(self) -> str:
|
||||
"""Gets the wrapped class name."""
|
||||
return self._wrapped.__class__.__name__
|
||||
@@ -0,0 +1,46 @@
|
||||
# Copyright (c) 2012, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""MySQL Connector/Python version information
|
||||
|
||||
The file version.py gets installed and is available after installation
|
||||
as mysql.connector.version.
|
||||
"""
|
||||
|
||||
VERSION = (9, 7, 0, "", 1)
|
||||
|
||||
# pylint: disable=consider-using-f-string
|
||||
if VERSION[3] and VERSION[4]:
|
||||
VERSION_TEXT = "{0}.{1}.{2}{3}{4}".format(*VERSION)
|
||||
else:
|
||||
VERSION_TEXT = "{0}.{1}.{2}".format(*VERSION[0:3])
|
||||
# pylint: enable=consider-using-f-string
|
||||
|
||||
VERSION_EXTRA = ""
|
||||
LICENSE = "GPLv2 with FOSS License Exception"
|
||||
EDITION = "" # Added in package names, after the version
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user