Primer commit del proyecto RSS

This commit is contained in:
jlimolina 2025-05-24 14:37:58 +02:00
commit 27c9515d29
1568 changed files with 252311 additions and 0 deletions

View file

@ -0,0 +1,127 @@
# Copyright (c) 2009, 2024, 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",
]

View file

@ -0,0 +1,111 @@
# Copyright (c) 2009, 2025, 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

View file

@ -0,0 +1,406 @@
# Copyright (c) 2024, 2025, 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

View file

@ -0,0 +1,255 @@
# Copyright (c) 2023, 2024, 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."""
__all__ = ["CMySQLConnection", "MySQLConnection", "connect"]
import random
from typing import Any
from ..constants import DEFAULT_CONFIGURATION
from ..errors import Error, InterfaceError, ProgrammingError
from ..pooling import ERROR_NO_CEXT
from .abstracts import MySQLConnectionAbstract
from .connection import MySQLConnection
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
async def connect(*args: Any, **kwargs: Any) -> 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
a `CMySQLConnection`) 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.aio import MySQLConnection, HAVE_CEXT
>>>
>>> cnx1 = await mysql.connector.aio.connect(user='joe', database='test')
>>> cnx2 = MySQLConnection(user='joe', database='test')
>>> await cnx2.connect()
>>>
>>> cnx3 = None
>>> if HAVE_CEXT:
>>> from mysql.connector.aio 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
]
# Failover
if "failover" in kwargs:
return await _get_failover_connection(**kwargs)
# 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:
cnx = CMySQLConnection(*args, **kwargs)
else:
cnx = MySQLConnection(*args, **kwargs)
await cnx.connect()
return cnx
async def _get_failover_connection(**kwargs: Any) -> 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 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")

View file

@ -0,0 +1,112 @@
# Copyright (c) 2009, 2025, 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

View file

@ -0,0 +1,335 @@
# Copyright (c) 2023, 2024, 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

View file

@ -0,0 +1,686 @@
# Copyright (c) 2023, 2024, 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

View file

@ -0,0 +1,33 @@
# Copyright (c) 2023, 2024, 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")

View file

@ -0,0 +1,761 @@
# Copyright (c) 2023, 2024, 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.CancelledError, asyncio.TimeoutError) as err:
raise ReadTimeoutError(errno=3024) from 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.CancelledError, asyncio.TimeoutError) as err:
raise WriteTimeoutError(errno=3024) from 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

View file

@ -0,0 +1,162 @@
# Copyright (c) 2023, 2024, 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")

View file

@ -0,0 +1,577 @@
# Copyright (c) 2023, 2024, 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

View file

@ -0,0 +1,595 @@
# Copyright (c) 2023, 2024, 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

View file

@ -0,0 +1,234 @@
# Copyright (c) 2023, 2024, 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)

View file

@ -0,0 +1,172 @@
# Copyright (c) 2024, 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)

View file

@ -0,0 +1,291 @@
# Copyright (c) 2023, 2025, 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) # type: ignore[arg-type]
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

View file

@ -0,0 +1,160 @@
# Copyright (c) 2023, 2024, 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

View file

@ -0,0 +1,105 @@
# Copyright (c) 2023, 2024, 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

View file

@ -0,0 +1,121 @@
# Copyright (c) 2023, 2024, 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

View file

@ -0,0 +1,109 @@
# Copyright (c) 2023, 2024, 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

View file

@ -0,0 +1,325 @@
# Copyright (c) 2023, 2024, 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

View file

@ -0,0 +1,155 @@
# Copyright (c) 2023, 2024, 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)

View file

@ -0,0 +1,385 @@
# Copyright (c) 2014, 2024, 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

View file

@ -0,0 +1,620 @@
# -*- coding: utf-8 -*- # pylint: disable=missing-module-docstring
# Copyright (c) 2013, 2024, 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

View file

@ -0,0 +1,772 @@
# Copyright (c) 2009, 2025, 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 typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
from .constants import (
MYSQL_VECTOR_TYPE_CODE,
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"""
type_name = value.__class__.__name__.lower()
try:
converted: MySQLProducedType = getattr(self, f"_{type_name}_to_mysql")(
value
)
return converted
except AttributeError:
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

View file

@ -0,0 +1,49 @@
# Copyright (c) 2014, 2024, 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

View file

@ -0,0 +1,92 @@
# Copyright (c) 2009, 2024, 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()

View file

@ -0,0 +1,653 @@
# Copyright (c) 2020, 2025, 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 BINARY %s",
"icontains": "LIKE %s",
"regex": "REGEXP BINARY %s",
"iregex": "REGEXP %s",
"gt": "> %s",
"gte": ">= %s",
"lt": "< %s",
"lte": "<= %s",
"startswith": "LIKE BINARY %s",
"endswith": "LIKE BINARY %s",
"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 BINARY CONCAT('%%', {}, '%%')",
"icontains": "LIKE CONCAT('%%', {}, '%%')",
"startswith": "LIKE BINARY CONCAT({}, '%%')",
"istartswith": "LIKE CONCAT({}, '%%')",
"endswith": "LIKE BINARY CONCAT('%%', {})",
"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 = {
"charset": "utf8",
"use_unicode": True,
"buffered": False,
"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."""
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."""
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."""
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)

View file

@ -0,0 +1,106 @@
# Copyright (c) 2020, 2024, 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)

View file

@ -0,0 +1,45 @@
# Copyright (c) 2020, 2024, 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",
]

View file

@ -0,0 +1,33 @@
# Copyright (c) 2020, 2024, 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"]

View file

@ -0,0 +1,50 @@
# Copyright (c) 2020, 2024, 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)

View file

@ -0,0 +1,461 @@
# Copyright (c) 2020, 2024, 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

View file

@ -0,0 +1,104 @@
# Copyright (c) 2020, 2024, 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)

Some files were not shown because too many files have changed in this diff Show more