초기화
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""MySQL Connector/Python - MySQL driver written in Python."""
|
||||
|
||||
try:
|
||||
from .connection_cext import CMySQLConnection
|
||||
except ImportError:
|
||||
HAVE_CEXT = False
|
||||
else:
|
||||
HAVE_CEXT = True
|
||||
|
||||
|
||||
from . import version
|
||||
from .connection import MySQLConnection
|
||||
from .constants import CharacterSet, ClientFlag, FieldFlag, FieldType, RefreshOption
|
||||
from .dbapi import (
|
||||
BINARY,
|
||||
DATETIME,
|
||||
NUMBER,
|
||||
ROWID,
|
||||
STRING,
|
||||
Binary,
|
||||
Date,
|
||||
DateFromTicks,
|
||||
Time,
|
||||
TimeFromTicks,
|
||||
Timestamp,
|
||||
TimestampFromTicks,
|
||||
apilevel,
|
||||
paramstyle,
|
||||
threadsafety,
|
||||
)
|
||||
from .errors import ( # pylint: disable=redefined-builtin
|
||||
DatabaseError,
|
||||
DataError,
|
||||
Error,
|
||||
IntegrityError,
|
||||
InterfaceError,
|
||||
InternalError,
|
||||
NotSupportedError,
|
||||
OperationalError,
|
||||
PoolError,
|
||||
ProgrammingError,
|
||||
Warning,
|
||||
custom_error_exception,
|
||||
)
|
||||
from .pooling import connect
|
||||
|
||||
Connect = connect
|
||||
|
||||
__version_info__ = version.VERSION
|
||||
"""This attribute indicates the Connector/Python version as an array
|
||||
of version components."""
|
||||
|
||||
__version__ = version.VERSION_TEXT
|
||||
"""This attribute indicates the Connector/Python version as a string."""
|
||||
|
||||
__all__ = [
|
||||
"MySQLConnection",
|
||||
"Connect",
|
||||
"custom_error_exception",
|
||||
# Some useful constants
|
||||
"FieldType",
|
||||
"FieldFlag",
|
||||
"ClientFlag",
|
||||
"CharacterSet",
|
||||
"RefreshOption",
|
||||
"HAVE_CEXT",
|
||||
# Error handling
|
||||
"Error",
|
||||
"Warning",
|
||||
"InterfaceError",
|
||||
"DatabaseError",
|
||||
"NotSupportedError",
|
||||
"DataError",
|
||||
"IntegrityError",
|
||||
"PoolError",
|
||||
"ProgrammingError",
|
||||
"OperationalError",
|
||||
"InternalError",
|
||||
# DBAPI PEP 249 required exports
|
||||
"connect",
|
||||
"apilevel",
|
||||
"threadsafety",
|
||||
"paramstyle",
|
||||
"Date",
|
||||
"Time",
|
||||
"Timestamp",
|
||||
"Binary",
|
||||
"DateFromTicks",
|
||||
"DateFromTicks",
|
||||
"TimestampFromTicks",
|
||||
"TimeFromTicks",
|
||||
"STRING",
|
||||
"BINARY",
|
||||
"NUMBER",
|
||||
"DATETIME",
|
||||
"ROWID",
|
||||
# C Extension
|
||||
"CMySQLConnection",
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Decorators Hub."""
|
||||
|
||||
import functools
|
||||
import warnings
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from .constants import RefreshOption
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
def cmd_refresh_verify_options() -> Callable:
|
||||
"""Decorator verifying which options are relevant and which aren't based on
|
||||
the server version the client is connecting to."""
|
||||
|
||||
def decorator(cmd_refresh: Callable) -> Callable:
|
||||
@functools.wraps(cmd_refresh)
|
||||
def wrapper(
|
||||
cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any
|
||||
) -> Callable:
|
||||
options: int = args[0]
|
||||
if (options & RefreshOption.GRANT) and cnx.server_version >= (
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
):
|
||||
warnings.warn(
|
||||
"As of MySQL Server 9.2.0, refreshing grant tables is not needed "
|
||||
"if you use statements GRANT, REVOKE, CREATE, DROP, or ALTER. "
|
||||
"You should expect this option to be unsupported in a future "
|
||||
"version of MySQL Connector/Python when MySQL Server removes it.",
|
||||
category=DeprecationWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
return cmd_refresh(cnx, options, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def handle_read_write_timeout() -> Callable:
|
||||
"""
|
||||
Decorator to close the current connection if a read or a write timeout
|
||||
is raised by the method passed via the func parameter.
|
||||
"""
|
||||
|
||||
def decorator(cnx_method: Callable) -> Callable:
|
||||
@functools.wraps(cnx_method)
|
||||
def handle_cnx_method(
|
||||
cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
try:
|
||||
return cnx_method(cnx, *args, **kwargs)
|
||||
except Exception as err:
|
||||
if isinstance(err, TimeoutError):
|
||||
cnx.close()
|
||||
raise err
|
||||
|
||||
return handle_cnx_method
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def deprecated(reason: str) -> Callable:
|
||||
"""Use it to decorate deprecated methods."""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Callable:
|
||||
warnings.warn(
|
||||
f"Call to deprecated function {func.__name__}. Reason: {reason}",
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,406 @@
|
||||
# Copyright (c) 2024, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Classes and methods utilized to work with MySQL Scripts."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from collections import deque
|
||||
from typing import Deque, Generator, Optional
|
||||
|
||||
from .errors import InterfaceError
|
||||
from .types import MySQLScriptPartition
|
||||
|
||||
DEFAULT_DELIMITER = b";"
|
||||
"""The default delimiter of MySQL Client and the only one
|
||||
recognized by the MySQL server protocol."""
|
||||
|
||||
DELIMITER_RESERVED_SYMBOLS = {
|
||||
"$": rb"\$",
|
||||
"^": rb"\^",
|
||||
"?": rb"\?",
|
||||
"(": rb"\(",
|
||||
")": rb"\)",
|
||||
"[": rb"\[",
|
||||
"]": rb"\]",
|
||||
"{": rb"\{",
|
||||
"}": rb"\}",
|
||||
".": rb"\.",
|
||||
"|": rb"\|",
|
||||
"+": rb"\+",
|
||||
"-": rb"\-",
|
||||
"*": rb"\*",
|
||||
}
|
||||
"""Symbols with a special meaning in regular expression contexts."""
|
||||
|
||||
DELIMITER_PATTERN: re.Pattern = re.compile(
|
||||
rb"""(delimiter\s+)(?=(?:[^"'`]*(?:"[^"]*"|'[^']*'|`[^`]*`))*[^"'`]*$)""",
|
||||
flags=re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
"""Regular expression pattern recognizing the delimiter command."""
|
||||
|
||||
|
||||
class MySQLScriptSplitter:
|
||||
"""Breaks a MySQL script into single statements.
|
||||
|
||||
It strips custom delimiters and comments along the way, except for comments
|
||||
representing a MySQL extension or optimizer hint.
|
||||
"""
|
||||
|
||||
_regex_sql_split_stmts = b"""(?=(?:[^"'`]*(?:"[^"]*"|'[^']*'|`[^`]*`))*[^"'`]*$)"""
|
||||
|
||||
def __init__(self, sql_script: bytes) -> None:
|
||||
"""Constructor."""
|
||||
self._code = sql_script
|
||||
self._single_stmts: Optional[list[bytes]] = None
|
||||
self._mappable_stmts: Optional[list[bytes]] = None
|
||||
self._re_sql_split_stmts: dict[bytes, re.Pattern] = {}
|
||||
|
||||
def _split_statement(self, code: bytes, delimiter: bytes) -> list[bytes]:
|
||||
"""Split code context by delimiter."""
|
||||
snippets = []
|
||||
|
||||
if delimiter not in self._re_sql_split_stmts:
|
||||
if b"\\" in delimiter:
|
||||
raise InterfaceError(
|
||||
"The backslash (\\) character is not a valid delimiter."
|
||||
)
|
||||
delimiter_pattern = [
|
||||
DELIMITER_RESERVED_SYMBOLS.get(char, char.encode())
|
||||
for char in delimiter.decode()
|
||||
]
|
||||
self._re_sql_split_stmts[delimiter] = re.compile(
|
||||
b"".join(delimiter_pattern) + self._regex_sql_split_stmts
|
||||
)
|
||||
|
||||
for snippet in self._re_sql_split_stmts[delimiter].split(code):
|
||||
snippet_strip = snippet.strip()
|
||||
if snippet_strip:
|
||||
snippets.append(snippet_strip)
|
||||
|
||||
return snippets
|
||||
|
||||
@staticmethod
|
||||
def is_white_space_char(char: int) -> bool:
|
||||
"""Validates whether `char` is a white-space character or not."""
|
||||
return unicodedata.category(chr(char))[0] in {"Z"}
|
||||
|
||||
@staticmethod
|
||||
def is_control_char(char: int) -> bool:
|
||||
"""Validates whether `char` is a control character or not."""
|
||||
return unicodedata.category(chr(char))[0] in {"C"}
|
||||
|
||||
@staticmethod
|
||||
def split_by_control_char_or_white_space(string: bytes) -> list[bytes]:
|
||||
"""Split `string` by any control character or whitespace."""
|
||||
return re.split(rb"[\s\x00-\x1f\x7f-\x9f]", string)
|
||||
|
||||
@staticmethod
|
||||
def has_delimiter(code: bytes) -> bool:
|
||||
"""Validates whether `code` has the delimiter command pattern or not."""
|
||||
return re.search(DELIMITER_PATTERN, code) is not None
|
||||
|
||||
@staticmethod
|
||||
def remove_comments(code: bytes) -> bytes:
|
||||
"""Remove MySQL comments which include `--`-style, `#`-style
|
||||
and `C`-style comments.
|
||||
|
||||
A `--`-style comment spans from `--` to the end of the line.
|
||||
It requires the second dash to be
|
||||
followed by at least one whitespace or control character
|
||||
(such as a space, tab, newline, and so on).
|
||||
|
||||
A `#`-style comment spans from `#` to the end of the line.
|
||||
|
||||
A C-style comment spans from a `/*` sequence to the following `*/`
|
||||
sequence, as in the C programming language. This syntax enables a
|
||||
comment to extend over multiple lines because the beginning and
|
||||
closing sequences need not be on the same line.
|
||||
|
||||
**NOTE: Only C-style comments representing MySQL extensions or
|
||||
optimizer hints are preserved**. E.g.,
|
||||
|
||||
```
|
||||
/*! MySQL-specific code */
|
||||
|
||||
/*+ MySQL-specific code */
|
||||
```
|
||||
|
||||
*For Reference Manual- MySQL Comments*, see
|
||||
https://dev.mysql.com/doc/refman/en/comments.html.
|
||||
"""
|
||||
|
||||
def is_dash_style(b_str: bytes, b_char: int) -> bool:
|
||||
return b_str == b"--" and (
|
||||
MySQLScriptSplitter.is_control_char(b_char)
|
||||
or MySQLScriptSplitter.is_white_space_char(b_char)
|
||||
)
|
||||
|
||||
def is_hash_style(b_str: bytes) -> bool:
|
||||
return b_str == b"#"
|
||||
|
||||
def is_c_style(b_str: bytes, b_char: int) -> bool:
|
||||
return b_str == b"/*" and b_char not in {ord("!"), ord("+")}
|
||||
|
||||
buf = bytearray(b"")
|
||||
i, literal_ctx = 0, None
|
||||
line_break, single_quote, double_quote = ord("\n"), ord("'"), ord('"')
|
||||
while i < len(code):
|
||||
if literal_ctx is None:
|
||||
style = None
|
||||
if is_dash_style(buf[-2:], code[i]):
|
||||
style = "--"
|
||||
elif is_hash_style(buf[-1:]):
|
||||
style = "#"
|
||||
elif is_c_style(buf[-2:], code[i]):
|
||||
style = "/*"
|
||||
if style is not None:
|
||||
if style in ("--", "#"):
|
||||
while i < len(code) and code[i] != line_break:
|
||||
i += 1
|
||||
else:
|
||||
while i + 1 < len(code) and code[i : i + 2] != b"*/":
|
||||
i += 1
|
||||
i += 2
|
||||
|
||||
for _ in range(len(style)):
|
||||
buf.pop()
|
||||
|
||||
while buf and (
|
||||
MySQLScriptSplitter.is_control_char(buf[-1])
|
||||
or MySQLScriptSplitter.is_white_space_char(buf[-1])
|
||||
):
|
||||
buf.pop()
|
||||
|
||||
continue
|
||||
|
||||
if literal_ctx is None and code[i] in [single_quote, double_quote]:
|
||||
literal_ctx = code[i]
|
||||
elif literal_ctx is not None and code[i] in {literal_ctx, line_break}:
|
||||
literal_ctx = None
|
||||
|
||||
buf.append(code[i])
|
||||
i += 1
|
||||
|
||||
return bytes(buf)
|
||||
|
||||
def split_script(self, remove_comments: bool = True) -> list[bytes]:
|
||||
"""Splits the given script text into a sequence of individual statements.
|
||||
|
||||
The word DELIMITER and any of its lower and upper case combinations
|
||||
such as delimiter, DeLiMiter, etc., are considered reserved words by
|
||||
the connector. Users must quote these when included in multi statements
|
||||
for other purposes different from declaring an actual statement delimiter;
|
||||
e.g., as names for tables, columns, variables, in comments, etc.
|
||||
|
||||
```
|
||||
CREATE TABLE `delimiter` (begin INT, end INT); -- I am a `DELimiTer` comment
|
||||
```
|
||||
|
||||
If they are not quoted, the statement-mapping will not produce the expected
|
||||
experience.
|
||||
|
||||
See https://dev.mysql.com/doc/refman/8.0/en/keywords.html to know more
|
||||
about quoting a reserved word.
|
||||
|
||||
*Note that comments are always ignored as they are not considered to be
|
||||
part of statements, with one exeception; **C-style comments representing
|
||||
MySQL extensions or optimizer hints are preserved***.
|
||||
"""
|
||||
# If it was already computed, then skip computation and use the cache
|
||||
if self._single_stmts is not None:
|
||||
return self._single_stmts
|
||||
|
||||
# initialize variables
|
||||
self._single_stmts = []
|
||||
delimiter = DEFAULT_DELIMITER
|
||||
buf: list[bytes] = []
|
||||
prev = b""
|
||||
|
||||
# remove comments
|
||||
if remove_comments:
|
||||
code = MySQLScriptSplitter.remove_comments(code=self._code)
|
||||
else:
|
||||
code = self._code
|
||||
|
||||
# let's split the script by `delimiter pattern` - the pattern is also
|
||||
# included in the returned list.
|
||||
for curr in re.split(pattern=DELIMITER_PATTERN, string=code):
|
||||
# Checking if the previous substring is a "switch of context
|
||||
# (delimiter)" point.
|
||||
if re.search(DELIMITER_PATTERN, prev):
|
||||
# The next delimiter must be the sequence of chars until
|
||||
# reaching a control char or whitespace
|
||||
next_delimiter = self.split_by_control_char_or_white_space(curr)[0]
|
||||
|
||||
# We shall remove the delimiter command from the code
|
||||
buf.pop()
|
||||
|
||||
# At this point buf includes all the code where `delimiter` applies.
|
||||
self._single_stmts.extend(
|
||||
self._split_statement(code=b" ".join(buf), delimiter=delimiter)
|
||||
)
|
||||
|
||||
# From the current substring, let's take everything but the
|
||||
# "next delimiter" portion. Also, let's update the delimiter
|
||||
delimiter, buf = next_delimiter, [curr[len(next_delimiter) :]]
|
||||
else:
|
||||
# Let's accumulate
|
||||
buf.append(curr)
|
||||
|
||||
# track the previous substring
|
||||
prev = curr
|
||||
|
||||
# Ensure there are no loose ends
|
||||
if buf:
|
||||
self._single_stmts.extend(
|
||||
self._split_statement(code=b" ".join(buf), delimiter=delimiter)
|
||||
)
|
||||
|
||||
return self._single_stmts
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self._code.decode("utf-8")
|
||||
|
||||
|
||||
def split_multi_statement(
|
||||
sql_code: bytes,
|
||||
map_results: bool = False,
|
||||
) -> Generator[MySQLScriptPartition, None, None]:
|
||||
"""Breaks a MySQL script into sub-scripts.
|
||||
|
||||
If the given script uses `DELIMITER` statements (which are not recognized
|
||||
by MySQL Server), the connector will parse such statements to remove them
|
||||
from the script and substitute delimiters as needed. This pre-processing
|
||||
may cause a performance hit when using long scripts. Note that when enabling
|
||||
`map_results`, the script is expected to use `DELIMITER` statements in order
|
||||
to split the script into multiple query strings.
|
||||
|
||||
Args:
|
||||
sql_code: MySQL script.
|
||||
map_results: If True, each sub-script is `statement-result` mappable.
|
||||
|
||||
Returns:
|
||||
A generator of typed dictionaries with keys `single_stmts` and `mappable_stmts`.
|
||||
|
||||
If mapping disabled and no delimiters detected, it returns a 1-item generator,
|
||||
the field `single_stmts` is an empty list and the `mappable_stmt` field
|
||||
corresponds to the unmodified script, that may be mappable.
|
||||
|
||||
If mapping disabled and delimiters detected, it returns a 1-item generator,
|
||||
the field `single_stmts` is a list including all the single statements
|
||||
found in the script and the `mappable_stmt` field corresponds to the processed
|
||||
script (delimiters are stripped) that may be mappable.
|
||||
|
||||
If maping enabled, the script is broken into mappable partitions. It returns
|
||||
an N-item generator (as many items as computed partitions), the field
|
||||
`single_stmts` is a list including all the single statements of the partition
|
||||
and the `mappable_stmt` field corresponds to the sub-script (partition) that
|
||||
is guaranteed to be mappable.
|
||||
|
||||
Raises:
|
||||
`InterfaceError` if an invalid delimiter string is found.
|
||||
"""
|
||||
if not MySQLScriptSplitter.has_delimiter(sql_code) and not map_results:
|
||||
# For those users executing single statements or scripts with no delimiters,
|
||||
# they can get a performance boost by bypassing the multi statement splitter.
|
||||
|
||||
# Simply wrap the multi statement up (so it can be processed correctly
|
||||
# downstream) and return it as it is.
|
||||
yield MySQLScriptPartition(single_stmts=deque([]), mappable_stmt=sql_code)
|
||||
return
|
||||
|
||||
tok = MySQLScriptSplitter(sql_script=sql_code)
|
||||
|
||||
# The splitter splits the sql code into many single statements
|
||||
# while also getting rid of the delimiters (if any).
|
||||
stmts = tok.split_script()
|
||||
|
||||
# if there are not statements to execute
|
||||
if not stmts:
|
||||
# Simply wrap the multi statement up (so it can be processed correctly
|
||||
# downstream).
|
||||
yield MySQLScriptPartition(single_stmts=deque([b""]), mappable_stmt=b"")
|
||||
return
|
||||
|
||||
if not map_results:
|
||||
# group single statements into a unique and possibly no mappable
|
||||
# multi statement.
|
||||
yield MySQLScriptPartition(
|
||||
single_stmts=deque(stmts), mappable_stmt=b";\n".join(stmts)
|
||||
)
|
||||
return
|
||||
|
||||
# group single statements into one or more mappable multi statements.
|
||||
i = 0
|
||||
partition_ids = (j for j, stmt in enumerate(stmts) if stmt[:5].upper() == b"CALL ")
|
||||
for j in partition_ids:
|
||||
if j > i:
|
||||
yield (
|
||||
MySQLScriptPartition(
|
||||
mappable_stmt=b";\n".join(stmts[i:j]),
|
||||
single_stmts=deque(stmts[i:j]),
|
||||
)
|
||||
)
|
||||
yield MySQLScriptPartition(
|
||||
mappable_stmt=stmts[j], single_stmts=deque([stmts[j]])
|
||||
)
|
||||
i = j + 1
|
||||
|
||||
if i < len(stmts):
|
||||
yield (
|
||||
MySQLScriptPartition(
|
||||
mappable_stmt=b";\n".join(stmts[i : len(stmts)]),
|
||||
single_stmts=deque(stmts[i : len(stmts)]),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_local_infile_filenames(script: bytes) -> Deque[str]:
|
||||
"""Scans the MySQL script looking for `filenames` (one for each
|
||||
`LOCAL INFILE` statement found).
|
||||
|
||||
Arguments:
|
||||
script: a MySQL script that may include one or more `LOCAL INFILE` statements.
|
||||
|
||||
Returns:
|
||||
filenames: a list of filenames (one for each `LOCAL INFILE` statement found).
|
||||
An empty list is returned if no matches are found.
|
||||
"""
|
||||
matches = re.findall(
|
||||
pattern=rb"""LOCAL\s+INFILE\s+(["'])((?:\\\1|(?:(?!\1)).)*)(\1)""",
|
||||
string=MySQLScriptSplitter.remove_comments(script),
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if not matches or len(matches[0]) != 3:
|
||||
return deque([])
|
||||
|
||||
# If there is a match, we get ("'", "filename", "'") , that's to say,
|
||||
# the 1st and 3rd entries are the quote symbols, and the 2nd the actual filename.
|
||||
return deque([match[1].decode("utf-8") for match in matches])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""MySQL Connector/Python - MySQL driver written in Python."""
|
||||
|
||||
from .connection import MySQLConnection, MySQLConnectionAbstract
|
||||
from .pooling import MySQLConnectionPool, PooledMySQLConnection, connect
|
||||
|
||||
__all__ = [
|
||||
"MySQLConnection",
|
||||
"connect",
|
||||
"MySQLConnectionAbstract",
|
||||
"MySQLConnectionPool",
|
||||
"PooledMySQLConnection",
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Decorators Hub."""
|
||||
|
||||
import functools
|
||||
import warnings
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from ..constants import RefreshOption
|
||||
from ..errors import ReadTimeoutError, WriteTimeoutError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
def cmd_refresh_verify_options() -> Callable:
|
||||
"""Decorator verifying which options are relevant and which aren't based on
|
||||
the server version the client is connecting to."""
|
||||
|
||||
def decorator(cmd_refresh: Callable) -> Callable:
|
||||
@functools.wraps(cmd_refresh)
|
||||
async def wrapper(
|
||||
cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any
|
||||
) -> Callable:
|
||||
options: int = args[0]
|
||||
if (options & RefreshOption.GRANT) and cnx.server_version >= (
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
):
|
||||
warnings.warn(
|
||||
"As of MySQL Server 9.2.0, refreshing grant tables is not needed "
|
||||
"if you use statements GRANT, REVOKE, CREATE, DROP, or ALTER. "
|
||||
"You should expect this option to be unsupported in a future "
|
||||
"version of MySQL Connector/Python when MySQL Server removes it.",
|
||||
category=DeprecationWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
return await cmd_refresh(cnx, options, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def deprecated(reason: str) -> Callable:
|
||||
"""Use it to decorate deprecated methods."""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Callable:
|
||||
warnings.warn(
|
||||
f"Call to deprecated function {func.__name__}. Reason: {reason}",
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def handle_read_write_timeout() -> Callable:
|
||||
"""
|
||||
Decorator to close the current connection if a read or a write timeout
|
||||
is raised by the method passed via the func parameter.
|
||||
"""
|
||||
|
||||
def decorator(cnx_method: Callable) -> Callable:
|
||||
@functools.wraps(cnx_method)
|
||||
async def handle_cnx_method(
|
||||
cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
try:
|
||||
return await cnx_method(cnx, *args, **kwargs)
|
||||
except Exception as err:
|
||||
if isinstance(err, (ReadTimeoutError, WriteTimeoutError)):
|
||||
await cnx.close()
|
||||
raise err
|
||||
|
||||
return handle_cnx_method
|
||||
|
||||
return decorator
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Implementing support for MySQL Authentication Plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["MySQLAuthenticator"]
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from ..errors import InterfaceError, NotSupportedError, get_exception
|
||||
from ..protocol import (
|
||||
AUTH_SWITCH_STATUS,
|
||||
DEFAULT_CHARSET_ID,
|
||||
DEFAULT_MAX_ALLOWED_PACKET,
|
||||
ERR_STATUS,
|
||||
EXCHANGE_FURTHER_STATUS,
|
||||
MFA_STATUS,
|
||||
OK_STATUS,
|
||||
)
|
||||
from ..types import HandShakeType
|
||||
from .logger import logger
|
||||
from .plugins import MySQLAuthPlugin, get_auth_plugin
|
||||
from .protocol import MySQLProtocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .network import MySQLSocket
|
||||
|
||||
|
||||
class MySQLAuthenticator:
|
||||
"""Implements the authentication phase."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Constructor."""
|
||||
self._username: str = ""
|
||||
self._passwords: Dict[int, str] = {}
|
||||
self._plugin_config: Dict[str, Any] = {}
|
||||
self._ssl_enabled: bool = False
|
||||
self._auth_strategy: Optional[MySQLAuthPlugin] = None
|
||||
self._auth_plugin_class: Optional[str] = None
|
||||
|
||||
@property
|
||||
def ssl_enabled(self) -> bool:
|
||||
"""Signals whether or not SSL is enabled."""
|
||||
return self._ssl_enabled
|
||||
|
||||
@property
|
||||
def plugin_config(self) -> Dict[str, Any]:
|
||||
"""Custom arguments that are being provided to the authentication plugin.
|
||||
|
||||
The parameters defined here will override the ones defined in the
|
||||
auth plugin itself.
|
||||
|
||||
The plugin config is a read-only property - the plugin configuration
|
||||
provided when invoking `authenticate()` is recorded and can be queried
|
||||
by accessing this property.
|
||||
|
||||
Returns:
|
||||
dict: The latest plugin configuration provided when invoking
|
||||
`authenticate()`.
|
||||
"""
|
||||
return self._plugin_config
|
||||
|
||||
def update_plugin_config(self, config: Dict[str, Any]) -> None:
|
||||
"""Update the 'plugin_config' instance variable"""
|
||||
self._plugin_config.update(config)
|
||||
|
||||
def _switch_auth_strategy(
|
||||
self,
|
||||
new_strategy_name: str,
|
||||
strategy_class: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password_factor: int = 1,
|
||||
) -> None:
|
||||
"""Switch the authorization plugin.
|
||||
|
||||
Args:
|
||||
new_strategy_name: New authorization plugin name to switch to.
|
||||
strategy_class: New authorization plugin class to switch to
|
||||
(has higher precedence than the authorization plugin name).
|
||||
username: Username to be used - if not defined, the username
|
||||
provided when `authentication()` was invoked is used.
|
||||
password_factor: Up to three levels of authentication (MFA) are allowed,
|
||||
hence you can choose the password corresponding to the 1st,
|
||||
2nd, or 3rd factor - 1st is the default.
|
||||
"""
|
||||
if username is None:
|
||||
username = self._username
|
||||
|
||||
if strategy_class is None:
|
||||
strategy_class = self._auth_plugin_class
|
||||
|
||||
logger.debug("Switching to strategy %s", new_strategy_name)
|
||||
self._auth_strategy = get_auth_plugin(
|
||||
plugin_name=new_strategy_name, auth_plugin_class=strategy_class
|
||||
)(
|
||||
username,
|
||||
self._passwords.get(password_factor, ""),
|
||||
ssl_enabled=self.ssl_enabled,
|
||||
)
|
||||
|
||||
async def _mfa_n_factor(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
pkt: bytes,
|
||||
) -> Optional[bytes]:
|
||||
"""Handle MFA (Multi-Factor Authentication) response.
|
||||
|
||||
Up to three levels of authentication (MFA) are allowed.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
pkt: MFA response.
|
||||
|
||||
Returns:
|
||||
ok_packet: If last server's response is an OK packet.
|
||||
None: If last server's response isn't an OK packet and no ERROR was raised.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If got an invalid N factor.
|
||||
errors.ErrorTypes: If got an ERROR response.
|
||||
"""
|
||||
n_factor = 2
|
||||
while pkt[4] == MFA_STATUS:
|
||||
if n_factor not in self._passwords:
|
||||
raise InterfaceError(
|
||||
"Failed Multi Factor Authentication (invalid N factor)"
|
||||
)
|
||||
|
||||
new_strategy_name, auth_data = MySQLProtocol.parse_auth_next_factor(pkt)
|
||||
self._switch_auth_strategy(new_strategy_name, password_factor=n_factor)
|
||||
logger.debug("MFA %i factor %s", n_factor, self._auth_strategy.name)
|
||||
|
||||
pkt = await self._auth_strategy.auth_switch_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == EXCHANGE_FURTHER_STATUS:
|
||||
auth_data = MySQLProtocol.parse_auth_more_data(pkt)
|
||||
pkt = await self._auth_strategy.auth_more_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == OK_STATUS:
|
||||
logger.debug("MFA completed succesfully")
|
||||
return pkt
|
||||
|
||||
if pkt[4] == ERR_STATUS:
|
||||
raise get_exception(pkt)
|
||||
|
||||
n_factor += 1
|
||||
|
||||
logger.warning("MFA terminated with a no ok packet")
|
||||
return None
|
||||
|
||||
async def _handle_server_response(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
pkt: bytes,
|
||||
) -> Optional[bytes]:
|
||||
"""Handle server's response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
pkt: Server's response after completing the `HandShakeResponse`.
|
||||
|
||||
Returns:
|
||||
ok_packet: If last server's response is an OK packet.
|
||||
None: If last server's response isn't an OK packet and no ERROR was raised.
|
||||
|
||||
Raises:
|
||||
errors.ErrorTypes: If got an ERROR response.
|
||||
NotSupportedError: If got Authentication with old (insecure) passwords.
|
||||
"""
|
||||
if pkt[4] == AUTH_SWITCH_STATUS and len(pkt) == 5:
|
||||
raise NotSupportedError(
|
||||
"Authentication with old (insecure) passwords "
|
||||
"is not supported. For more information, lookup "
|
||||
"Password Hashing in the latest MySQL manual"
|
||||
)
|
||||
|
||||
if pkt[4] == AUTH_SWITCH_STATUS:
|
||||
logger.debug("Server's response is an auth switch request")
|
||||
new_strategy_name, auth_data = MySQLProtocol.parse_auth_switch_request(pkt)
|
||||
self._switch_auth_strategy(new_strategy_name)
|
||||
pkt = await self._auth_strategy.auth_switch_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == EXCHANGE_FURTHER_STATUS:
|
||||
logger.debug("Exchanging further packets")
|
||||
auth_data = MySQLProtocol.parse_auth_more_data(pkt)
|
||||
pkt = await self._auth_strategy.auth_more_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == OK_STATUS:
|
||||
logger.debug("%s completed succesfully", self._auth_strategy.name)
|
||||
return pkt
|
||||
|
||||
if pkt[4] == MFA_STATUS:
|
||||
logger.debug("Starting multi-factor authentication")
|
||||
logger.debug("MFA 1 factor %s", self._auth_strategy.name)
|
||||
return await self._mfa_n_factor(sock, pkt)
|
||||
|
||||
if pkt[4] == ERR_STATUS:
|
||||
raise get_exception(pkt)
|
||||
|
||||
return None
|
||||
|
||||
async def authenticate(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
handshake: HandShakeType,
|
||||
username: str = "",
|
||||
password1: str = "",
|
||||
password2: str = "",
|
||||
password3: str = "",
|
||||
database: Optional[str] = None,
|
||||
charset: int = DEFAULT_CHARSET_ID,
|
||||
client_flags: int = 0,
|
||||
ssl_enabled: bool = False,
|
||||
max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET,
|
||||
auth_plugin: Optional[str] = None,
|
||||
auth_plugin_class: Optional[str] = None,
|
||||
conn_attrs: Optional[Dict[str, str]] = None,
|
||||
is_change_user_request: bool = False,
|
||||
read_timeout: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> bytes:
|
||||
"""Perform the authentication phase.
|
||||
|
||||
During re-authentication you must set `is_change_user_request` to True.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
handshake: Initial handshake.
|
||||
username: Account's username.
|
||||
password1: Account's password factor 1.
|
||||
password2: Account's password factor 2.
|
||||
password3: Account's password factor 3.
|
||||
database: Initial database name for the connection.
|
||||
charset: Client charset (see [1]), only the lower 8-bits.
|
||||
client_flags: Integer representing client capabilities flags.
|
||||
ssl_enabled: Boolean indicating whether SSL is enabled,
|
||||
max_allowed_packet: Maximum packet size.
|
||||
auth_plugin: Authorization plugin name.
|
||||
auth_plugin_class: Authorization plugin class (has higher precedence
|
||||
than the authorization plugin name).
|
||||
conn_attrs: Connection attributes.
|
||||
is_change_user_request: Whether is a `change user request` operation or not.
|
||||
read_timeout: Timeout in seconds upto which the connector should wait for
|
||||
the server to reply back before raising an ReadTimeoutError.
|
||||
write_timeout: Timeout in seconds upto which the connector should spend to
|
||||
send data to the server before raising an WriteTimeoutError.
|
||||
|
||||
Returns:
|
||||
ok_packet: OK packet.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If OK packet is NULL.
|
||||
ReadTimeoutError: If the time taken for the server to reply back exceeds
|
||||
'read_timeout' (if set).
|
||||
WriteTimeoutError: If the time taken to send data packets to the server
|
||||
exceeds 'write_timeout' (if set).
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\
|
||||
page_protocol_basic_character_set.html#a_protocol_character_set
|
||||
"""
|
||||
# update credentials, plugin config and plugin class
|
||||
self._username = username
|
||||
self._passwords = {1: password1, 2: password2, 3: password3}
|
||||
self._ssl_enabled = ssl_enabled
|
||||
self._auth_plugin_class = auth_plugin_class
|
||||
|
||||
# client's handshake response
|
||||
response_payload, self._auth_strategy = MySQLProtocol.make_auth(
|
||||
handshake=handshake,
|
||||
username=username,
|
||||
password=password1,
|
||||
database=database,
|
||||
charset=charset,
|
||||
client_flags=client_flags,
|
||||
max_allowed_packet=max_allowed_packet,
|
||||
auth_plugin=auth_plugin,
|
||||
auth_plugin_class=auth_plugin_class,
|
||||
conn_attrs=conn_attrs,
|
||||
is_change_user_request=is_change_user_request,
|
||||
ssl_enabled=self.ssl_enabled,
|
||||
plugin_config=self.plugin_config,
|
||||
)
|
||||
|
||||
# client sends transaction response
|
||||
send_args = (
|
||||
(0, 0, write_timeout)
|
||||
if is_change_user_request
|
||||
else (None, None, write_timeout)
|
||||
)
|
||||
await sock.write(response_payload, *send_args)
|
||||
|
||||
# server replies back
|
||||
pkt = bytes(await sock.read(read_timeout))
|
||||
|
||||
ok_pkt = await self._handle_server_response(sock, pkt)
|
||||
if ok_pkt is None:
|
||||
raise InterfaceError("Got a NULL ok_pkt") from None
|
||||
|
||||
return ok_pkt
|
||||
@@ -0,0 +1,686 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""This module contains the MySQL Server Character Sets."""
|
||||
|
||||
__all__ = ["Charset", "charsets"]
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import DefaultDict, Dict, Optional, Sequence, Tuple
|
||||
|
||||
from ..errors import ProgrammingError
|
||||
|
||||
|
||||
@dataclass
|
||||
class Charset:
|
||||
"""Dataclass representing a character set."""
|
||||
|
||||
charset_id: int
|
||||
name: str
|
||||
collation: str
|
||||
is_default: bool
|
||||
|
||||
|
||||
class Charsets:
|
||||
"""MySQL supported character sets and collations class.
|
||||
|
||||
This class holds the list of character sets with their collations supported by
|
||||
MySQL, making available methods to get character sets by name, collation, or ID.
|
||||
It uses a sparse matrix or tree-like representation using a dict in a dict to hold
|
||||
the character set name and collations combinations.
|
||||
The list is hardcoded, so we avoid a database query when getting the name of the
|
||||
used character set or collation.
|
||||
|
||||
The call of ``charsets.set_mysql_major_version()`` should be done before using any
|
||||
of the retrieval methods.
|
||||
|
||||
Usage:
|
||||
>>> from mysql.connector.aio.charsets import charsets
|
||||
>>> charsets.set_mysql_major_version(8)
|
||||
>>> charsets.get_by_name("utf-8")
|
||||
Charset(charset_id=255,
|
||||
name='utf8mb4',
|
||||
collation='utf8mb4_0900_ai_ci',
|
||||
is_default=True)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._charset_id_store: Dict[int, Charset] = {}
|
||||
self._collation_store: Dict[str, Charset] = {}
|
||||
self._name_store: DefaultDict[str, Dict[str, Charset]] = defaultdict(dict)
|
||||
self._mysql_major_version: Optional[int] = None
|
||||
|
||||
def set_mysql_major_version(self, version: int) -> None:
|
||||
"""Set the MySQL major version.
|
||||
|
||||
Sets what tuple should be used based on the MySQL major version to store the
|
||||
list of character sets and collations.
|
||||
|
||||
Args:
|
||||
version: The MySQL major version (i.e. 8 or 5)
|
||||
"""
|
||||
self._mysql_major_version = version
|
||||
self._charset_id_store.clear()
|
||||
self._collation_store.clear()
|
||||
self._name_store.clear()
|
||||
|
||||
charsets_tuple: Sequence[Tuple[int, str, str, bool]] = None
|
||||
if version >= 8:
|
||||
charsets_tuple = MYSQL_8_CHARSETS
|
||||
elif version == 5:
|
||||
charsets_tuple = MYSQL_5_CHARSETS
|
||||
else:
|
||||
raise ProgrammingError("Invalid MySQL major version")
|
||||
|
||||
for charset_id, name, collation, is_default in charsets_tuple:
|
||||
charset = Charset(charset_id, name, collation, is_default)
|
||||
self._charset_id_store[charset_id] = charset
|
||||
self._collation_store[collation] = charset
|
||||
self._name_store[name][collation] = charset
|
||||
|
||||
def get_by_id(self, charset_id: int) -> Charset:
|
||||
"""Get character set by ID.
|
||||
|
||||
Args:
|
||||
charset_id: The charset ID.
|
||||
|
||||
Returns:
|
||||
Charset: The Charset dataclass instance.
|
||||
"""
|
||||
try:
|
||||
return self._charset_id_store[charset_id]
|
||||
except KeyError as err:
|
||||
raise ProgrammingError(f"Character set ID {charset_id} unknown") from err
|
||||
|
||||
def get_by_collation(self, collation: str) -> Charset:
|
||||
"""Get character set by collation.
|
||||
|
||||
Args:
|
||||
collation: The collation name.
|
||||
|
||||
Returns:
|
||||
Charset: The Charset dataclass instance.
|
||||
"""
|
||||
try:
|
||||
return self._collation_store[collation]
|
||||
except KeyError as err:
|
||||
raise ProgrammingError(f"Collation {collation} unknown") from err
|
||||
|
||||
def get_by_name(self, name: str) -> Charset:
|
||||
"""Get character set by name.
|
||||
|
||||
Args:
|
||||
name: The charset name.
|
||||
|
||||
Returns:
|
||||
Charset: The Charset dataclass instance.
|
||||
"""
|
||||
try:
|
||||
if name in ("utf8", "utf-8") and self._mysql_major_version == 8:
|
||||
name = "utf8mb4"
|
||||
for charset in self._name_store[name].values():
|
||||
if charset.is_default:
|
||||
return charset
|
||||
except KeyError as err:
|
||||
raise ProgrammingError(f"Character set name {name} unknown") from err
|
||||
raise ProgrammingError(f"No default was found for character set '{name}'")
|
||||
|
||||
def get_by_name_and_collation(self, name: str, collation: str) -> Charset:
|
||||
"""Get character set by name and collation.
|
||||
|
||||
Args:
|
||||
name: The charset name.
|
||||
collation: The collation name.
|
||||
|
||||
Returns:
|
||||
Charset: The Charset dataclass instance.
|
||||
"""
|
||||
try:
|
||||
return self._name_store[name][collation]
|
||||
except KeyError as err:
|
||||
raise ProgrammingError(
|
||||
f"Character set name '{name}' with collation '{collation}' not found"
|
||||
) from err
|
||||
|
||||
|
||||
MYSQL_8_CHARSETS = (
|
||||
(1, "big5", "big5_chinese_ci", True),
|
||||
(2, "latin2", "latin2_czech_cs", False),
|
||||
(3, "dec8", "dec8_swedish_ci", True),
|
||||
(4, "cp850", "cp850_general_ci", True),
|
||||
(5, "latin1", "latin1_german1_ci", False),
|
||||
(6, "hp8", "hp8_english_ci", True),
|
||||
(7, "koi8r", "koi8r_general_ci", True),
|
||||
(8, "latin1", "latin1_swedish_ci", True),
|
||||
(9, "latin2", "latin2_general_ci", True),
|
||||
(10, "swe7", "swe7_swedish_ci", True),
|
||||
(11, "ascii", "ascii_general_ci", True),
|
||||
(12, "ujis", "ujis_japanese_ci", True),
|
||||
(13, "sjis", "sjis_japanese_ci", True),
|
||||
(14, "cp1251", "cp1251_bulgarian_ci", False),
|
||||
(15, "latin1", "latin1_danish_ci", False),
|
||||
(16, "hebrew", "hebrew_general_ci", True),
|
||||
(18, "tis620", "tis620_thai_ci", True),
|
||||
(19, "euckr", "euckr_korean_ci", True),
|
||||
(20, "latin7", "latin7_estonian_cs", False),
|
||||
(21, "latin2", "latin2_hungarian_ci", False),
|
||||
(22, "koi8u", "koi8u_general_ci", True),
|
||||
(23, "cp1251", "cp1251_ukrainian_ci", False),
|
||||
(24, "gb2312", "gb2312_chinese_ci", True),
|
||||
(25, "greek", "greek_general_ci", True),
|
||||
(26, "cp1250", "cp1250_general_ci", True),
|
||||
(27, "latin2", "latin2_croatian_ci", False),
|
||||
(28, "gbk", "gbk_chinese_ci", True),
|
||||
(29, "cp1257", "cp1257_lithuanian_ci", False),
|
||||
(30, "latin5", "latin5_turkish_ci", True),
|
||||
(31, "latin1", "latin1_german2_ci", False),
|
||||
(32, "armscii8", "armscii8_general_ci", True),
|
||||
(33, "utf8mb3", "utf8mb3_general_ci", True),
|
||||
(34, "cp1250", "cp1250_czech_cs", False),
|
||||
(35, "ucs2", "ucs2_general_ci", True),
|
||||
(36, "cp866", "cp866_general_ci", True),
|
||||
(37, "keybcs2", "keybcs2_general_ci", True),
|
||||
(38, "macce", "macce_general_ci", True),
|
||||
(39, "macroman", "macroman_general_ci", True),
|
||||
(40, "cp852", "cp852_general_ci", True),
|
||||
(41, "latin7", "latin7_general_ci", True),
|
||||
(42, "latin7", "latin7_general_cs", False),
|
||||
(43, "macce", "macce_bin", False),
|
||||
(44, "cp1250", "cp1250_croatian_ci", False),
|
||||
(45, "utf8mb4", "utf8mb4_general_ci", False),
|
||||
(46, "utf8mb4", "utf8mb4_bin", False),
|
||||
(47, "latin1", "latin1_bin", False),
|
||||
(48, "latin1", "latin1_general_ci", False),
|
||||
(49, "latin1", "latin1_general_cs", False),
|
||||
(50, "cp1251", "cp1251_bin", False),
|
||||
(51, "cp1251", "cp1251_general_ci", True),
|
||||
(52, "cp1251", "cp1251_general_cs", False),
|
||||
(53, "macroman", "macroman_bin", False),
|
||||
(54, "utf16", "utf16_general_ci", True),
|
||||
(55, "utf16", "utf16_bin", False),
|
||||
(56, "utf16le", "utf16le_general_ci", True),
|
||||
(57, "cp1256", "cp1256_general_ci", True),
|
||||
(58, "cp1257", "cp1257_bin", False),
|
||||
(59, "cp1257", "cp1257_general_ci", True),
|
||||
(60, "utf32", "utf32_general_ci", True),
|
||||
(61, "utf32", "utf32_bin", False),
|
||||
(62, "utf16le", "utf16le_bin", False),
|
||||
(63, "binary", "binary", True),
|
||||
(64, "armscii8", "armscii8_bin", False),
|
||||
(65, "ascii", "ascii_bin", False),
|
||||
(66, "cp1250", "cp1250_bin", False),
|
||||
(67, "cp1256", "cp1256_bin", False),
|
||||
(68, "cp866", "cp866_bin", False),
|
||||
(69, "dec8", "dec8_bin", False),
|
||||
(70, "greek", "greek_bin", False),
|
||||
(71, "hebrew", "hebrew_bin", False),
|
||||
(72, "hp8", "hp8_bin", False),
|
||||
(73, "keybcs2", "keybcs2_bin", False),
|
||||
(74, "koi8r", "koi8r_bin", False),
|
||||
(75, "koi8u", "koi8u_bin", False),
|
||||
(76, "utf8mb3", "utf8mb3_tolower_ci", False),
|
||||
(77, "latin2", "latin2_bin", False),
|
||||
(78, "latin5", "latin5_bin", False),
|
||||
(79, "latin7", "latin7_bin", False),
|
||||
(80, "cp850", "cp850_bin", False),
|
||||
(81, "cp852", "cp852_bin", False),
|
||||
(82, "swe7", "swe7_bin", False),
|
||||
(83, "utf8mb3", "utf8mb3_bin", False),
|
||||
(84, "big5", "big5_bin", False),
|
||||
(85, "euckr", "euckr_bin", False),
|
||||
(86, "gb2312", "gb2312_bin", False),
|
||||
(87, "gbk", "gbk_bin", False),
|
||||
(88, "sjis", "sjis_bin", False),
|
||||
(89, "tis620", "tis620_bin", False),
|
||||
(90, "ucs2", "ucs2_bin", False),
|
||||
(91, "ujis", "ujis_bin", False),
|
||||
(92, "geostd8", "geostd8_general_ci", True),
|
||||
(93, "geostd8", "geostd8_bin", False),
|
||||
(94, "latin1", "latin1_spanish_ci", False),
|
||||
(95, "cp932", "cp932_japanese_ci", True),
|
||||
(96, "cp932", "cp932_bin", False),
|
||||
(97, "eucjpms", "eucjpms_japanese_ci", True),
|
||||
(98, "eucjpms", "eucjpms_bin", False),
|
||||
(99, "cp1250", "cp1250_polish_ci", False),
|
||||
(101, "utf16", "utf16_unicode_ci", False),
|
||||
(102, "utf16", "utf16_icelandic_ci", False),
|
||||
(103, "utf16", "utf16_latvian_ci", False),
|
||||
(104, "utf16", "utf16_romanian_ci", False),
|
||||
(105, "utf16", "utf16_slovenian_ci", False),
|
||||
(106, "utf16", "utf16_polish_ci", False),
|
||||
(107, "utf16", "utf16_estonian_ci", False),
|
||||
(108, "utf16", "utf16_spanish_ci", False),
|
||||
(109, "utf16", "utf16_swedish_ci", False),
|
||||
(110, "utf16", "utf16_turkish_ci", False),
|
||||
(111, "utf16", "utf16_czech_ci", False),
|
||||
(112, "utf16", "utf16_danish_ci", False),
|
||||
(113, "utf16", "utf16_lithuanian_ci", False),
|
||||
(114, "utf16", "utf16_slovak_ci", False),
|
||||
(115, "utf16", "utf16_spanish2_ci", False),
|
||||
(116, "utf16", "utf16_roman_ci", False),
|
||||
(117, "utf16", "utf16_persian_ci", False),
|
||||
(118, "utf16", "utf16_esperanto_ci", False),
|
||||
(119, "utf16", "utf16_hungarian_ci", False),
|
||||
(120, "utf16", "utf16_sinhala_ci", False),
|
||||
(121, "utf16", "utf16_german2_ci", False),
|
||||
(122, "utf16", "utf16_croatian_ci", False),
|
||||
(123, "utf16", "utf16_unicode_520_ci", False),
|
||||
(124, "utf16", "utf16_vietnamese_ci", False),
|
||||
(128, "ucs2", "ucs2_unicode_ci", False),
|
||||
(129, "ucs2", "ucs2_icelandic_ci", False),
|
||||
(130, "ucs2", "ucs2_latvian_ci", False),
|
||||
(131, "ucs2", "ucs2_romanian_ci", False),
|
||||
(132, "ucs2", "ucs2_slovenian_ci", False),
|
||||
(133, "ucs2", "ucs2_polish_ci", False),
|
||||
(134, "ucs2", "ucs2_estonian_ci", False),
|
||||
(135, "ucs2", "ucs2_spanish_ci", False),
|
||||
(136, "ucs2", "ucs2_swedish_ci", False),
|
||||
(137, "ucs2", "ucs2_turkish_ci", False),
|
||||
(138, "ucs2", "ucs2_czech_ci", False),
|
||||
(139, "ucs2", "ucs2_danish_ci", False),
|
||||
(140, "ucs2", "ucs2_lithuanian_ci", False),
|
||||
(141, "ucs2", "ucs2_slovak_ci", False),
|
||||
(142, "ucs2", "ucs2_spanish2_ci", False),
|
||||
(143, "ucs2", "ucs2_roman_ci", False),
|
||||
(144, "ucs2", "ucs2_persian_ci", False),
|
||||
(145, "ucs2", "ucs2_esperanto_ci", False),
|
||||
(146, "ucs2", "ucs2_hungarian_ci", False),
|
||||
(147, "ucs2", "ucs2_sinhala_ci", False),
|
||||
(148, "ucs2", "ucs2_german2_ci", False),
|
||||
(149, "ucs2", "ucs2_croatian_ci", False),
|
||||
(150, "ucs2", "ucs2_unicode_520_ci", False),
|
||||
(151, "ucs2", "ucs2_vietnamese_ci", False),
|
||||
(159, "ucs2", "ucs2_general_mysql500_ci", False),
|
||||
(160, "utf32", "utf32_unicode_ci", False),
|
||||
(161, "utf32", "utf32_icelandic_ci", False),
|
||||
(162, "utf32", "utf32_latvian_ci", False),
|
||||
(163, "utf32", "utf32_romanian_ci", False),
|
||||
(164, "utf32", "utf32_slovenian_ci", False),
|
||||
(165, "utf32", "utf32_polish_ci", False),
|
||||
(166, "utf32", "utf32_estonian_ci", False),
|
||||
(167, "utf32", "utf32_spanish_ci", False),
|
||||
(168, "utf32", "utf32_swedish_ci", False),
|
||||
(169, "utf32", "utf32_turkish_ci", False),
|
||||
(170, "utf32", "utf32_czech_ci", False),
|
||||
(171, "utf32", "utf32_danish_ci", False),
|
||||
(172, "utf32", "utf32_lithuanian_ci", False),
|
||||
(173, "utf32", "utf32_slovak_ci", False),
|
||||
(174, "utf32", "utf32_spanish2_ci", False),
|
||||
(175, "utf32", "utf32_roman_ci", False),
|
||||
(176, "utf32", "utf32_persian_ci", False),
|
||||
(177, "utf32", "utf32_esperanto_ci", False),
|
||||
(178, "utf32", "utf32_hungarian_ci", False),
|
||||
(179, "utf32", "utf32_sinhala_ci", False),
|
||||
(180, "utf32", "utf32_german2_ci", False),
|
||||
(181, "utf32", "utf32_croatian_ci", False),
|
||||
(182, "utf32", "utf32_unicode_520_ci", False),
|
||||
(183, "utf32", "utf32_vietnamese_ci", False),
|
||||
(192, "utf8mb3", "utf8mb3_unicode_ci", False),
|
||||
(193, "utf8mb3", "utf8mb3_icelandic_ci", False),
|
||||
(194, "utf8mb3", "utf8mb3_latvian_ci", False),
|
||||
(195, "utf8mb3", "utf8mb3_romanian_ci", False),
|
||||
(196, "utf8mb3", "utf8mb3_slovenian_ci", False),
|
||||
(197, "utf8mb3", "utf8mb3_polish_ci", False),
|
||||
(198, "utf8mb3", "utf8mb3_estonian_ci", False),
|
||||
(199, "utf8mb3", "utf8mb3_spanish_ci", False),
|
||||
(200, "utf8mb3", "utf8mb3_swedish_ci", False),
|
||||
(201, "utf8mb3", "utf8mb3_turkish_ci", False),
|
||||
(202, "utf8mb3", "utf8mb3_czech_ci", False),
|
||||
(203, "utf8mb3", "utf8mb3_danish_ci", False),
|
||||
(204, "utf8mb3", "utf8mb3_lithuanian_ci", False),
|
||||
(205, "utf8mb3", "utf8mb3_slovak_ci", False),
|
||||
(206, "utf8mb3", "utf8mb3_spanish2_ci", False),
|
||||
(207, "utf8mb3", "utf8mb3_roman_ci", False),
|
||||
(208, "utf8mb3", "utf8mb3_persian_ci", False),
|
||||
(209, "utf8mb3", "utf8mb3_esperanto_ci", False),
|
||||
(210, "utf8mb3", "utf8mb3_hungarian_ci", False),
|
||||
(211, "utf8mb3", "utf8mb3_sinhala_ci", False),
|
||||
(212, "utf8mb3", "utf8mb3_german2_ci", False),
|
||||
(213, "utf8mb3", "utf8mb3_croatian_ci", False),
|
||||
(214, "utf8mb3", "utf8mb3_unicode_520_ci", False),
|
||||
(215, "utf8mb3", "utf8mb3_vietnamese_ci", False),
|
||||
(223, "utf8mb3", "utf8mb3_general_mysql500_ci", False),
|
||||
(224, "utf8mb4", "utf8mb4_unicode_ci", False),
|
||||
(225, "utf8mb4", "utf8mb4_icelandic_ci", False),
|
||||
(226, "utf8mb4", "utf8mb4_latvian_ci", False),
|
||||
(227, "utf8mb4", "utf8mb4_romanian_ci", False),
|
||||
(228, "utf8mb4", "utf8mb4_slovenian_ci", False),
|
||||
(229, "utf8mb4", "utf8mb4_polish_ci", False),
|
||||
(230, "utf8mb4", "utf8mb4_estonian_ci", False),
|
||||
(231, "utf8mb4", "utf8mb4_spanish_ci", False),
|
||||
(232, "utf8mb4", "utf8mb4_swedish_ci", False),
|
||||
(233, "utf8mb4", "utf8mb4_turkish_ci", False),
|
||||
(234, "utf8mb4", "utf8mb4_czech_ci", False),
|
||||
(235, "utf8mb4", "utf8mb4_danish_ci", False),
|
||||
(236, "utf8mb4", "utf8mb4_lithuanian_ci", False),
|
||||
(237, "utf8mb4", "utf8mb4_slovak_ci", False),
|
||||
(238, "utf8mb4", "utf8mb4_spanish2_ci", False),
|
||||
(239, "utf8mb4", "utf8mb4_roman_ci", False),
|
||||
(240, "utf8mb4", "utf8mb4_persian_ci", False),
|
||||
(241, "utf8mb4", "utf8mb4_esperanto_ci", False),
|
||||
(242, "utf8mb4", "utf8mb4_hungarian_ci", False),
|
||||
(243, "utf8mb4", "utf8mb4_sinhala_ci", False),
|
||||
(244, "utf8mb4", "utf8mb4_german2_ci", False),
|
||||
(245, "utf8mb4", "utf8mb4_croatian_ci", False),
|
||||
(246, "utf8mb4", "utf8mb4_unicode_520_ci", False),
|
||||
(247, "utf8mb4", "utf8mb4_vietnamese_ci", False),
|
||||
(248, "gb18030", "gb18030_chinese_ci", True),
|
||||
(249, "gb18030", "gb18030_bin", False),
|
||||
(250, "gb18030", "gb18030_unicode_520_ci", False),
|
||||
(255, "utf8mb4", "utf8mb4_0900_ai_ci", True),
|
||||
(256, "utf8mb4", "utf8mb4_de_pb_0900_ai_ci", False),
|
||||
(257, "utf8mb4", "utf8mb4_is_0900_ai_ci", False),
|
||||
(258, "utf8mb4", "utf8mb4_lv_0900_ai_ci", False),
|
||||
(259, "utf8mb4", "utf8mb4_ro_0900_ai_ci", False),
|
||||
(260, "utf8mb4", "utf8mb4_sl_0900_ai_ci", False),
|
||||
(261, "utf8mb4", "utf8mb4_pl_0900_ai_ci", False),
|
||||
(262, "utf8mb4", "utf8mb4_et_0900_ai_ci", False),
|
||||
(263, "utf8mb4", "utf8mb4_es_0900_ai_ci", False),
|
||||
(264, "utf8mb4", "utf8mb4_sv_0900_ai_ci", False),
|
||||
(265, "utf8mb4", "utf8mb4_tr_0900_ai_ci", False),
|
||||
(266, "utf8mb4", "utf8mb4_cs_0900_ai_ci", False),
|
||||
(267, "utf8mb4", "utf8mb4_da_0900_ai_ci", False),
|
||||
(268, "utf8mb4", "utf8mb4_lt_0900_ai_ci", False),
|
||||
(269, "utf8mb4", "utf8mb4_sk_0900_ai_ci", False),
|
||||
(270, "utf8mb4", "utf8mb4_es_trad_0900_ai_ci", False),
|
||||
(271, "utf8mb4", "utf8mb4_la_0900_ai_ci", False),
|
||||
(273, "utf8mb4", "utf8mb4_eo_0900_ai_ci", False),
|
||||
(274, "utf8mb4", "utf8mb4_hu_0900_ai_ci", False),
|
||||
(275, "utf8mb4", "utf8mb4_hr_0900_ai_ci", False),
|
||||
(277, "utf8mb4", "utf8mb4_vi_0900_ai_ci", False),
|
||||
(278, "utf8mb4", "utf8mb4_0900_as_cs", False),
|
||||
(279, "utf8mb4", "utf8mb4_de_pb_0900_as_cs", False),
|
||||
(280, "utf8mb4", "utf8mb4_is_0900_as_cs", False),
|
||||
(281, "utf8mb4", "utf8mb4_lv_0900_as_cs", False),
|
||||
(282, "utf8mb4", "utf8mb4_ro_0900_as_cs", False),
|
||||
(283, "utf8mb4", "utf8mb4_sl_0900_as_cs", False),
|
||||
(284, "utf8mb4", "utf8mb4_pl_0900_as_cs", False),
|
||||
(285, "utf8mb4", "utf8mb4_et_0900_as_cs", False),
|
||||
(286, "utf8mb4", "utf8mb4_es_0900_as_cs", False),
|
||||
(287, "utf8mb4", "utf8mb4_sv_0900_as_cs", False),
|
||||
(288, "utf8mb4", "utf8mb4_tr_0900_as_cs", False),
|
||||
(289, "utf8mb4", "utf8mb4_cs_0900_as_cs", False),
|
||||
(290, "utf8mb4", "utf8mb4_da_0900_as_cs", False),
|
||||
(291, "utf8mb4", "utf8mb4_lt_0900_as_cs", False),
|
||||
(292, "utf8mb4", "utf8mb4_sk_0900_as_cs", False),
|
||||
(293, "utf8mb4", "utf8mb4_es_trad_0900_as_cs", False),
|
||||
(294, "utf8mb4", "utf8mb4_la_0900_as_cs", False),
|
||||
(296, "utf8mb4", "utf8mb4_eo_0900_as_cs", False),
|
||||
(297, "utf8mb4", "utf8mb4_hu_0900_as_cs", False),
|
||||
(298, "utf8mb4", "utf8mb4_hr_0900_as_cs", False),
|
||||
(300, "utf8mb4", "utf8mb4_vi_0900_as_cs", False),
|
||||
(303, "utf8mb4", "utf8mb4_ja_0900_as_cs", False),
|
||||
(304, "utf8mb4", "utf8mb4_ja_0900_as_cs_ks", False),
|
||||
(305, "utf8mb4", "utf8mb4_0900_as_ci", False),
|
||||
(306, "utf8mb4", "utf8mb4_ru_0900_ai_ci", False),
|
||||
(307, "utf8mb4", "utf8mb4_ru_0900_as_cs", False),
|
||||
(308, "utf8mb4", "utf8mb4_zh_0900_as_cs", False),
|
||||
(309, "utf8mb4", "utf8mb4_0900_bin", False),
|
||||
(310, "utf8mb4", "utf8mb4_nb_0900_ai_ci", False),
|
||||
(311, "utf8mb4", "utf8mb4_nb_0900_as_cs", False),
|
||||
(312, "utf8mb4", "utf8mb4_nn_0900_ai_ci", False),
|
||||
(313, "utf8mb4", "utf8mb4_nn_0900_as_cs", False),
|
||||
(314, "utf8mb4", "utf8mb4_sr_latn_0900_ai_ci", False),
|
||||
(315, "utf8mb4", "utf8mb4_sr_latn_0900_as_cs", False),
|
||||
(316, "utf8mb4", "utf8mb4_bs_0900_ai_ci", False),
|
||||
(317, "utf8mb4", "utf8mb4_bs_0900_as_cs", False),
|
||||
(318, "utf8mb4", "utf8mb4_bg_0900_ai_ci", False),
|
||||
(319, "utf8mb4", "utf8mb4_bg_0900_as_cs", False),
|
||||
(320, "utf8mb4", "utf8mb4_gl_0900_ai_ci", False),
|
||||
(321, "utf8mb4", "utf8mb4_gl_0900_as_cs", False),
|
||||
(322, "utf8mb4", "utf8mb4_mn_cyrl_0900_ai_ci", False),
|
||||
(323, "utf8mb4", "utf8mb4_mn_cyrl_0900_as_cs", False),
|
||||
)
|
||||
|
||||
MYSQL_5_CHARSETS = (
|
||||
(1, "big5", "big5_chinese_ci", True),
|
||||
(2, "latin2", "latin2_czech_cs", False),
|
||||
(3, "dec8", "dec8_swedish_ci", True),
|
||||
(4, "cp850", "cp850_general_ci", True),
|
||||
(5, "latin1", "latin1_german1_ci", False),
|
||||
(6, "hp8", "hp8_english_ci", True),
|
||||
(7, "koi8r", "koi8r_general_ci", True),
|
||||
(8, "latin1", "latin1_swedish_ci", True),
|
||||
(9, "latin2", "latin2_general_ci", True),
|
||||
(10, "swe7", "swe7_swedish_ci", True),
|
||||
(11, "ascii", "ascii_general_ci", True),
|
||||
(12, "ujis", "ujis_japanese_ci", True),
|
||||
(13, "sjis", "sjis_japanese_ci", True),
|
||||
(14, "cp1251", "cp1251_bulgarian_ci", False),
|
||||
(15, "latin1", "latin1_danish_ci", False),
|
||||
(16, "hebrew", "hebrew_general_ci", True),
|
||||
(18, "tis620", "tis620_thai_ci", True),
|
||||
(19, "euckr", "euckr_korean_ci", True),
|
||||
(20, "latin7", "latin7_estonian_cs", False),
|
||||
(21, "latin2", "latin2_hungarian_ci", False),
|
||||
(22, "koi8u", "koi8u_general_ci", True),
|
||||
(23, "cp1251", "cp1251_ukrainian_ci", False),
|
||||
(24, "gb2312", "gb2312_chinese_ci", True),
|
||||
(25, "greek", "greek_general_ci", True),
|
||||
(26, "cp1250", "cp1250_general_ci", True),
|
||||
(27, "latin2", "latin2_croatian_ci", False),
|
||||
(28, "gbk", "gbk_chinese_ci", True),
|
||||
(29, "cp1257", "cp1257_lithuanian_ci", False),
|
||||
(30, "latin5", "latin5_turkish_ci", True),
|
||||
(31, "latin1", "latin1_german2_ci", False),
|
||||
(32, "armscii8", "armscii8_general_ci", True),
|
||||
(33, "utf8", "utf8_general_ci", True),
|
||||
(34, "cp1250", "cp1250_czech_cs", False),
|
||||
(35, "ucs2", "ucs2_general_ci", True),
|
||||
(36, "cp866", "cp866_general_ci", True),
|
||||
(37, "keybcs2", "keybcs2_general_ci", True),
|
||||
(38, "macce", "macce_general_ci", True),
|
||||
(39, "macroman", "macroman_general_ci", True),
|
||||
(40, "cp852", "cp852_general_ci", True),
|
||||
(41, "latin7", "latin7_general_ci", True),
|
||||
(42, "latin7", "latin7_general_cs", False),
|
||||
(43, "macce", "macce_bin", False),
|
||||
(44, "cp1250", "cp1250_croatian_ci", False),
|
||||
(45, "utf8mb4", "utf8mb4_general_ci", True),
|
||||
(46, "utf8mb4", "utf8mb4_bin", False),
|
||||
(47, "latin1", "latin1_bin", False),
|
||||
(48, "latin1", "latin1_general_ci", False),
|
||||
(49, "latin1", "latin1_general_cs", False),
|
||||
(50, "cp1251", "cp1251_bin", False),
|
||||
(51, "cp1251", "cp1251_general_ci", True),
|
||||
(52, "cp1251", "cp1251_general_cs", False),
|
||||
(53, "macroman", "macroman_bin", False),
|
||||
(54, "utf16", "utf16_general_ci", True),
|
||||
(55, "utf16", "utf16_bin", False),
|
||||
(56, "utf16le", "utf16le_general_ci", True),
|
||||
(57, "cp1256", "cp1256_general_ci", True),
|
||||
(58, "cp1257", "cp1257_bin", False),
|
||||
(59, "cp1257", "cp1257_general_ci", True),
|
||||
(60, "utf32", "utf32_general_ci", True),
|
||||
(61, "utf32", "utf32_bin", False),
|
||||
(62, "utf16le", "utf16le_bin", False),
|
||||
(63, "binary", "binary", True),
|
||||
(64, "armscii8", "armscii8_bin", False),
|
||||
(65, "ascii", "ascii_bin", False),
|
||||
(66, "cp1250", "cp1250_bin", False),
|
||||
(67, "cp1256", "cp1256_bin", False),
|
||||
(68, "cp866", "cp866_bin", False),
|
||||
(69, "dec8", "dec8_bin", False),
|
||||
(70, "greek", "greek_bin", False),
|
||||
(71, "hebrew", "hebrew_bin", False),
|
||||
(72, "hp8", "hp8_bin", False),
|
||||
(73, "keybcs2", "keybcs2_bin", False),
|
||||
(74, "koi8r", "koi8r_bin", False),
|
||||
(75, "koi8u", "koi8u_bin", False),
|
||||
(77, "latin2", "latin2_bin", False),
|
||||
(78, "latin5", "latin5_bin", False),
|
||||
(79, "latin7", "latin7_bin", False),
|
||||
(80, "cp850", "cp850_bin", False),
|
||||
(81, "cp852", "cp852_bin", False),
|
||||
(82, "swe7", "swe7_bin", False),
|
||||
(83, "utf8", "utf8_bin", False),
|
||||
(84, "big5", "big5_bin", False),
|
||||
(85, "euckr", "euckr_bin", False),
|
||||
(86, "gb2312", "gb2312_bin", False),
|
||||
(87, "gbk", "gbk_bin", False),
|
||||
(88, "sjis", "sjis_bin", False),
|
||||
(89, "tis620", "tis620_bin", False),
|
||||
(90, "ucs2", "ucs2_bin", False),
|
||||
(91, "ujis", "ujis_bin", False),
|
||||
(92, "geostd8", "geostd8_general_ci", True),
|
||||
(93, "geostd8", "geostd8_bin", False),
|
||||
(94, "latin1", "latin1_spanish_ci", False),
|
||||
(95, "cp932", "cp932_japanese_ci", True),
|
||||
(96, "cp932", "cp932_bin", False),
|
||||
(97, "eucjpms", "eucjpms_japanese_ci", True),
|
||||
(98, "eucjpms", "eucjpms_bin", False),
|
||||
(99, "cp1250", "cp1250_polish_ci", False),
|
||||
(101, "utf16", "utf16_unicode_ci", False),
|
||||
(102, "utf16", "utf16_icelandic_ci", False),
|
||||
(103, "utf16", "utf16_latvian_ci", False),
|
||||
(104, "utf16", "utf16_romanian_ci", False),
|
||||
(105, "utf16", "utf16_slovenian_ci", False),
|
||||
(106, "utf16", "utf16_polish_ci", False),
|
||||
(107, "utf16", "utf16_estonian_ci", False),
|
||||
(108, "utf16", "utf16_spanish_ci", False),
|
||||
(109, "utf16", "utf16_swedish_ci", False),
|
||||
(110, "utf16", "utf16_turkish_ci", False),
|
||||
(111, "utf16", "utf16_czech_ci", False),
|
||||
(112, "utf16", "utf16_danish_ci", False),
|
||||
(113, "utf16", "utf16_lithuanian_ci", False),
|
||||
(114, "utf16", "utf16_slovak_ci", False),
|
||||
(115, "utf16", "utf16_spanish2_ci", False),
|
||||
(116, "utf16", "utf16_roman_ci", False),
|
||||
(117, "utf16", "utf16_persian_ci", False),
|
||||
(118, "utf16", "utf16_esperanto_ci", False),
|
||||
(119, "utf16", "utf16_hungarian_ci", False),
|
||||
(120, "utf16", "utf16_sinhala_ci", False),
|
||||
(121, "utf16", "utf16_german2_ci", False),
|
||||
(122, "utf16", "utf16_croatian_ci", False),
|
||||
(123, "utf16", "utf16_unicode_520_ci", False),
|
||||
(124, "utf16", "utf16_vietnamese_ci", False),
|
||||
(128, "ucs2", "ucs2_unicode_ci", False),
|
||||
(129, "ucs2", "ucs2_icelandic_ci", False),
|
||||
(130, "ucs2", "ucs2_latvian_ci", False),
|
||||
(131, "ucs2", "ucs2_romanian_ci", False),
|
||||
(132, "ucs2", "ucs2_slovenian_ci", False),
|
||||
(133, "ucs2", "ucs2_polish_ci", False),
|
||||
(134, "ucs2", "ucs2_estonian_ci", False),
|
||||
(135, "ucs2", "ucs2_spanish_ci", False),
|
||||
(136, "ucs2", "ucs2_swedish_ci", False),
|
||||
(137, "ucs2", "ucs2_turkish_ci", False),
|
||||
(138, "ucs2", "ucs2_czech_ci", False),
|
||||
(139, "ucs2", "ucs2_danish_ci", False),
|
||||
(140, "ucs2", "ucs2_lithuanian_ci", False),
|
||||
(141, "ucs2", "ucs2_slovak_ci", False),
|
||||
(142, "ucs2", "ucs2_spanish2_ci", False),
|
||||
(143, "ucs2", "ucs2_roman_ci", False),
|
||||
(144, "ucs2", "ucs2_persian_ci", False),
|
||||
(145, "ucs2", "ucs2_esperanto_ci", False),
|
||||
(146, "ucs2", "ucs2_hungarian_ci", False),
|
||||
(147, "ucs2", "ucs2_sinhala_ci", False),
|
||||
(148, "ucs2", "ucs2_german2_ci", False),
|
||||
(149, "ucs2", "ucs2_croatian_ci", False),
|
||||
(150, "ucs2", "ucs2_unicode_520_ci", False),
|
||||
(151, "ucs2", "ucs2_vietnamese_ci", False),
|
||||
(159, "ucs2", "ucs2_general_mysql500_ci", False),
|
||||
(160, "utf32", "utf32_unicode_ci", False),
|
||||
(161, "utf32", "utf32_icelandic_ci", False),
|
||||
(162, "utf32", "utf32_latvian_ci", False),
|
||||
(163, "utf32", "utf32_romanian_ci", False),
|
||||
(164, "utf32", "utf32_slovenian_ci", False),
|
||||
(165, "utf32", "utf32_polish_ci", False),
|
||||
(166, "utf32", "utf32_estonian_ci", False),
|
||||
(167, "utf32", "utf32_spanish_ci", False),
|
||||
(168, "utf32", "utf32_swedish_ci", False),
|
||||
(169, "utf32", "utf32_turkish_ci", False),
|
||||
(170, "utf32", "utf32_czech_ci", False),
|
||||
(171, "utf32", "utf32_danish_ci", False),
|
||||
(172, "utf32", "utf32_lithuanian_ci", False),
|
||||
(173, "utf32", "utf32_slovak_ci", False),
|
||||
(174, "utf32", "utf32_spanish2_ci", False),
|
||||
(175, "utf32", "utf32_roman_ci", False),
|
||||
(176, "utf32", "utf32_persian_ci", False),
|
||||
(177, "utf32", "utf32_esperanto_ci", False),
|
||||
(178, "utf32", "utf32_hungarian_ci", False),
|
||||
(179, "utf32", "utf32_sinhala_ci", False),
|
||||
(180, "utf32", "utf32_german2_ci", False),
|
||||
(181, "utf32", "utf32_croatian_ci", False),
|
||||
(182, "utf32", "utf32_unicode_520_ci", False),
|
||||
(183, "utf32", "utf32_vietnamese_ci", False),
|
||||
(192, "utf8", "utf8_unicode_ci", False),
|
||||
(193, "utf8", "utf8_icelandic_ci", False),
|
||||
(194, "utf8", "utf8_latvian_ci", False),
|
||||
(195, "utf8", "utf8_romanian_ci", False),
|
||||
(196, "utf8", "utf8_slovenian_ci", False),
|
||||
(197, "utf8", "utf8_polish_ci", False),
|
||||
(198, "utf8", "utf8_estonian_ci", False),
|
||||
(199, "utf8", "utf8_spanish_ci", False),
|
||||
(200, "utf8", "utf8_swedish_ci", False),
|
||||
(201, "utf8", "utf8_turkish_ci", False),
|
||||
(202, "utf8", "utf8_czech_ci", False),
|
||||
(203, "utf8", "utf8_danish_ci", False),
|
||||
(204, "utf8", "utf8_lithuanian_ci", False),
|
||||
(205, "utf8", "utf8_slovak_ci", False),
|
||||
(206, "utf8", "utf8_spanish2_ci", False),
|
||||
(207, "utf8", "utf8_roman_ci", False),
|
||||
(208, "utf8", "utf8_persian_ci", False),
|
||||
(209, "utf8", "utf8_esperanto_ci", False),
|
||||
(210, "utf8", "utf8_hungarian_ci", False),
|
||||
(211, "utf8", "utf8_sinhala_ci", False),
|
||||
(212, "utf8", "utf8_german2_ci", False),
|
||||
(213, "utf8", "utf8_croatian_ci", False),
|
||||
(214, "utf8", "utf8_unicode_520_ci", False),
|
||||
(215, "utf8", "utf8_vietnamese_ci", False),
|
||||
(223, "utf8", "utf8_general_mysql500_ci", False),
|
||||
(224, "utf8mb4", "utf8mb4_unicode_ci", False),
|
||||
(225, "utf8mb4", "utf8mb4_icelandic_ci", False),
|
||||
(226, "utf8mb4", "utf8mb4_latvian_ci", False),
|
||||
(227, "utf8mb4", "utf8mb4_romanian_ci", False),
|
||||
(228, "utf8mb4", "utf8mb4_slovenian_ci", False),
|
||||
(229, "utf8mb4", "utf8mb4_polish_ci", False),
|
||||
(230, "utf8mb4", "utf8mb4_estonian_ci", False),
|
||||
(231, "utf8mb4", "utf8mb4_spanish_ci", False),
|
||||
(232, "utf8mb4", "utf8mb4_swedish_ci", False),
|
||||
(233, "utf8mb4", "utf8mb4_turkish_ci", False),
|
||||
(234, "utf8mb4", "utf8mb4_czech_ci", False),
|
||||
(235, "utf8mb4", "utf8mb4_danish_ci", False),
|
||||
(236, "utf8mb4", "utf8mb4_lithuanian_ci", False),
|
||||
(237, "utf8mb4", "utf8mb4_slovak_ci", False),
|
||||
(238, "utf8mb4", "utf8mb4_spanish2_ci", False),
|
||||
(239, "utf8mb4", "utf8mb4_roman_ci", False),
|
||||
(240, "utf8mb4", "utf8mb4_persian_ci", False),
|
||||
(241, "utf8mb4", "utf8mb4_esperanto_ci", False),
|
||||
(242, "utf8mb4", "utf8mb4_hungarian_ci", False),
|
||||
(243, "utf8mb4", "utf8mb4_sinhala_ci", False),
|
||||
(244, "utf8mb4", "utf8mb4_german2_ci", False),
|
||||
(245, "utf8mb4", "utf8mb4_croatian_ci", False),
|
||||
(246, "utf8mb4", "utf8mb4_unicode_520_ci", False),
|
||||
(247, "utf8mb4", "utf8mb4_vietnamese_ci", False),
|
||||
(248, "gb18030", "gb18030_chinese_ci", True),
|
||||
(249, "gb18030", "gb18030_bin", False),
|
||||
(250, "gb18030", "gb18030_unicode_520_ci", False),
|
||||
)
|
||||
|
||||
charsets = Charsets()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Setup of the `mysql.connector.aio` logger."""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("mysql.connector.aio")
|
||||
@@ -0,0 +1,765 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# pylint: disable=dangerous-default-value
|
||||
|
||||
"""Module implementing low-level socket communication with MySQL servers."""
|
||||
|
||||
|
||||
__all__ = ["MySQLTcpSocket", "MySQLUnixSocket"]
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
try:
|
||||
import ssl
|
||||
|
||||
TLS_VERSIONS = {
|
||||
"TLSv1": ssl.PROTOCOL_TLSv1,
|
||||
"TLSv1.1": ssl.PROTOCOL_TLSv1_1,
|
||||
"TLSv1.2": ssl.PROTOCOL_TLSv1_2,
|
||||
"TLSv1.3": ssl.PROTOCOL_TLS,
|
||||
}
|
||||
except ImportError:
|
||||
ssl = None
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import deque
|
||||
from typing import Any, Deque, List, Optional, Tuple
|
||||
|
||||
from ..errors import (
|
||||
InterfaceError,
|
||||
NotSupportedError,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
ReadTimeoutError,
|
||||
WriteTimeoutError,
|
||||
)
|
||||
from ..network import (
|
||||
COMPRESSED_PACKET_HEADER_LENGTH,
|
||||
MAX_PAYLOAD_LENGTH,
|
||||
MIN_COMPRESS_LENGTH,
|
||||
PACKET_HEADER_LENGTH,
|
||||
)
|
||||
from .utils import StreamWriter, open_connection
|
||||
|
||||
|
||||
def _strioerror(err: IOError) -> str:
|
||||
"""Reformat the IOError error message.
|
||||
|
||||
This function reformats the IOError error message.
|
||||
"""
|
||||
return str(err) if not err.errno else f"{err.errno} {err.strerror}"
|
||||
|
||||
|
||||
class NetworkBroker(ABC):
|
||||
"""Broker class interface.
|
||||
|
||||
The network object is a broker used as a delegate by a socket object. Whenever the
|
||||
socket wants to deliver or get packets to or from the MySQL server it needs to rely
|
||||
on its network broker (netbroker).
|
||||
|
||||
The netbroker sends `payloads` and receives `packets`.
|
||||
|
||||
A packet is a bytes sequence, it has a header and body (referred to as payload).
|
||||
The first `PACKET_HEADER_LENGTH` or `COMPRESSED_PACKET_HEADER_LENGTH`
|
||||
(as appropriate) bytes correspond to the `header`, the remaining ones represent the
|
||||
`payload`.
|
||||
|
||||
The maximum payload length allowed to be sent per packet to the server is
|
||||
`MAX_PAYLOAD_LENGTH`. When `send` is called with a payload whose length is greater
|
||||
than `MAX_PAYLOAD_LENGTH` the netbroker breaks it down into packets, so the caller
|
||||
of `send` can provide payloads of arbitrary length.
|
||||
|
||||
Finally, data received by the netbroker comes directly from the server, expect to
|
||||
get a packet for each call to `recv`. The received packet contains a header and
|
||||
payload, the latter respecting `MAX_PAYLOAD_LENGTH`.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def write(
|
||||
self,
|
||||
writer: StreamWriter,
|
||||
address: str,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send `payload` to the MySQL server.
|
||||
|
||||
If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is
|
||||
broken down into packets.
|
||||
|
||||
Args:
|
||||
sock: Object holding the socket connection.
|
||||
address: Socket's location.
|
||||
payload: Packet's body to send.
|
||||
packet_number: Sequence id (packet ID) to attach to the header when sending
|
||||
plain packets.
|
||||
compressed_packet_number: Same as `packet_number` but used when sending
|
||||
compressed packets.
|
||||
write_timeout: Timeout in seconds before which sending a packet to the server
|
||||
should finish else WriteTimeoutError is raised.
|
||||
|
||||
|
||||
Raises:
|
||||
:class:`OperationalError`: If something goes wrong while sending packets to
|
||||
the MySQL server.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def read(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
address: str,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> bytearray:
|
||||
"""Get the next available packet from the MySQL server.
|
||||
|
||||
Args:
|
||||
sock: Object holding the socket connection.
|
||||
address: Socket's location.
|
||||
read_timeout: Timeout in seconds before which reading a packet from the server
|
||||
should finish.
|
||||
|
||||
Returns:
|
||||
packet: A packet from the MySQL server.
|
||||
|
||||
Raises:
|
||||
:class:`OperationalError`: If something goes wrong while receiving packets
|
||||
from the MySQL server.
|
||||
:class:`ReadTimeoutError`: If the time to receive a packet from the server takes
|
||||
longer than `read_timeout`.
|
||||
:class:`InterfaceError`: If something goes wrong while receiving packets
|
||||
from the MySQL server.
|
||||
"""
|
||||
|
||||
|
||||
class NetworkBrokerPlain(NetworkBroker):
|
||||
"""Broker class for MySQL socket communication."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pktnr: int = -1 # packet number
|
||||
|
||||
@staticmethod
|
||||
def get_header(pkt: bytes) -> Tuple[int, int]:
|
||||
"""Recover the header information from a packet."""
|
||||
if len(pkt) < PACKET_HEADER_LENGTH:
|
||||
raise ValueError("Can't recover header info from an incomplete packet")
|
||||
|
||||
pll, seqid = (
|
||||
struct.unpack("<I", pkt[0:3] + b"\x00")[0],
|
||||
pkt[3],
|
||||
)
|
||||
# payload length, sequence id
|
||||
return pll, seqid
|
||||
|
||||
def _set_next_pktnr(self, next_id: Optional[int] = None) -> None:
|
||||
"""Set the given packet id, if any, else increment packet id."""
|
||||
if next_id is None:
|
||||
self._pktnr += 1
|
||||
else:
|
||||
self._pktnr = next_id
|
||||
self._pktnr %= 256
|
||||
|
||||
async def _write_pkt(
|
||||
self,
|
||||
writer: StreamWriter,
|
||||
address: str,
|
||||
pkt: bytes,
|
||||
) -> None:
|
||||
"""Write packet to the comm channel."""
|
||||
try:
|
||||
writer.write(pkt)
|
||||
await writer.drain()
|
||||
except IOError as err:
|
||||
raise OperationalError(
|
||||
errno=2055, values=(address, _strioerror(err))
|
||||
) from err
|
||||
except AttributeError as err:
|
||||
raise OperationalError(errno=2006) from err
|
||||
|
||||
async def _read_chunk(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
size: int = 0,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> bytearray:
|
||||
"""Read `size` bytes from the comm channel."""
|
||||
try:
|
||||
pkt = bytearray(b"")
|
||||
while len(pkt) < size:
|
||||
chunk = await asyncio.wait_for(
|
||||
reader.read(size - len(pkt)), read_timeout
|
||||
)
|
||||
if not chunk:
|
||||
raise InterfaceError(errno=2013)
|
||||
pkt += chunk
|
||||
return pkt
|
||||
except asyncio.TimeoutError as err:
|
||||
raise ReadTimeoutError(errno=3024) from err
|
||||
except asyncio.CancelledError as err:
|
||||
raise err
|
||||
|
||||
async def write(
|
||||
self,
|
||||
writer: StreamWriter,
|
||||
address: str,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send payload to the MySQL server.
|
||||
|
||||
If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is
|
||||
broken down into packets.
|
||||
"""
|
||||
self._set_next_pktnr(packet_number)
|
||||
# If the payload is larger than or equal to MAX_PAYLOAD_LENGTH the length is
|
||||
# set to 2^24 - 1 (ff ff ff) and additional packets are sent with the rest of
|
||||
# the payload until the payload of a packet is less than MAX_PAYLOAD_LENGTH.
|
||||
offset = 0
|
||||
try:
|
||||
for _ in range(len(payload) // MAX_PAYLOAD_LENGTH):
|
||||
# payload_len, sequence_id, payload
|
||||
await asyncio.wait_for(
|
||||
self._write_pkt(
|
||||
writer,
|
||||
address,
|
||||
b"\xff" * 3
|
||||
+ struct.pack("<B", self._pktnr)
|
||||
+ payload[offset : offset + MAX_PAYLOAD_LENGTH],
|
||||
),
|
||||
write_timeout,
|
||||
)
|
||||
self._set_next_pktnr()
|
||||
offset += MAX_PAYLOAD_LENGTH
|
||||
await asyncio.wait_for(
|
||||
self._write_pkt(
|
||||
writer,
|
||||
address,
|
||||
struct.pack("<I", len(payload) - offset)[0:3]
|
||||
+ struct.pack("<B", self._pktnr)
|
||||
+ payload[offset:],
|
||||
),
|
||||
write_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError as err:
|
||||
raise WriteTimeoutError(errno=3024) from err
|
||||
except asyncio.CancelledError as err:
|
||||
raise err
|
||||
|
||||
async def read(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
address: str,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> bytearray:
|
||||
"""Receive `one` packet from the MySQL server."""
|
||||
try:
|
||||
# Read the header of the MySQL packet.
|
||||
header = await self._read_chunk(reader, PACKET_HEADER_LENGTH, read_timeout)
|
||||
|
||||
# Pull the payload length and sequence id.
|
||||
payload_len, self._pktnr = self.get_header(header)
|
||||
|
||||
# Read the payload, and return packet.
|
||||
return header + await self._read_chunk(reader, payload_len, read_timeout)
|
||||
except IOError as err:
|
||||
raise OperationalError(
|
||||
errno=2055, values=(address, _strioerror(err))
|
||||
) from err
|
||||
|
||||
|
||||
class NetworkBrokerCompressed(NetworkBrokerPlain):
|
||||
"""Broker class for MySQL socket communication."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._compressed_pktnr = -1
|
||||
self._queue_read: Deque[bytearray] = deque()
|
||||
|
||||
@staticmethod
|
||||
def _prepare_packets(payload: bytes, pktnr: int) -> List[bytes]:
|
||||
"""Prepare a payload for sending to the MySQL server."""
|
||||
offset = 0
|
||||
pkts = []
|
||||
|
||||
# If the payload is larger than or equal to MAX_PAYLOAD_LENGTH the length is
|
||||
# set to 2^24 - 1 (ff ff ff) and additional packets are sent with the rest of
|
||||
# the payload until the payload of a packet is less than MAX_PAYLOAD_LENGTH.
|
||||
for _ in range(len(payload) // MAX_PAYLOAD_LENGTH):
|
||||
# payload length + sequence id + payload
|
||||
pkts.append(
|
||||
b"\xff" * 3
|
||||
+ struct.pack("<B", pktnr)
|
||||
+ payload[offset : offset + MAX_PAYLOAD_LENGTH]
|
||||
)
|
||||
pktnr = (pktnr + 1) % 256
|
||||
offset += MAX_PAYLOAD_LENGTH
|
||||
pkts.append(
|
||||
struct.pack("<I", len(payload) - offset)[0:3]
|
||||
+ struct.pack("<B", pktnr)
|
||||
+ payload[offset:]
|
||||
)
|
||||
return pkts
|
||||
|
||||
@staticmethod
|
||||
def get_header(pkt: bytes) -> Tuple[int, int, int]: # type: ignore[override]
|
||||
"""Recover the header information from a packet."""
|
||||
if len(pkt) < COMPRESSED_PACKET_HEADER_LENGTH:
|
||||
raise ValueError("Can't recover header info from an incomplete packet")
|
||||
|
||||
compressed_pll, seqid, uncompressed_pll = (
|
||||
struct.unpack("<I", pkt[0:3] + b"\x00")[0],
|
||||
pkt[3],
|
||||
struct.unpack("<I", pkt[4:7] + b"\x00")[0],
|
||||
)
|
||||
# compressed payload length, sequence id, uncompressed payload length
|
||||
return compressed_pll, seqid, uncompressed_pll
|
||||
|
||||
def _set_next_compressed_pktnr(self, next_id: Optional[int] = None) -> None:
|
||||
"""Set the given packet id, if any, else increment packet id."""
|
||||
if next_id is None:
|
||||
self._compressed_pktnr += 1
|
||||
else:
|
||||
self._compressed_pktnr = next_id
|
||||
self._compressed_pktnr %= 256
|
||||
|
||||
async def _write_pkt(
|
||||
self,
|
||||
writer: StreamWriter,
|
||||
address: str,
|
||||
pkt: bytes,
|
||||
) -> None:
|
||||
"""Compress packet and write it to the comm channel."""
|
||||
compressed_pkt = zlib.compress(pkt)
|
||||
pkt = (
|
||||
struct.pack("<I", len(compressed_pkt))[0:3]
|
||||
+ struct.pack("<B", self._compressed_pktnr)
|
||||
+ struct.pack("<I", len(pkt))[0:3]
|
||||
+ compressed_pkt
|
||||
)
|
||||
return await super()._write_pkt(writer, address, pkt)
|
||||
|
||||
async def write(
|
||||
self,
|
||||
writer: StreamWriter,
|
||||
address: str,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send `payload` as compressed packets to the MySQL server.
|
||||
|
||||
If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is
|
||||
broken down into packets.
|
||||
"""
|
||||
# Get next packet numbers.
|
||||
self._set_next_pktnr(packet_number)
|
||||
self._set_next_compressed_pktnr(compressed_packet_number)
|
||||
try:
|
||||
payload_prep = bytearray(b"").join(
|
||||
self._prepare_packets(payload, self._pktnr)
|
||||
)
|
||||
if len(payload) >= MAX_PAYLOAD_LENGTH - PACKET_HEADER_LENGTH:
|
||||
# Sending a MySQL payload of the size greater or equal to 2^24 - 5 via
|
||||
# compression leads to at least one extra compressed packet WHY? let's say
|
||||
# len(payload) is MAX_PAYLOAD_LENGTH - 3; when preparing the payload, a
|
||||
# header of size PACKET_HEADER_LENGTH is pre-appended to the payload.
|
||||
# This means that len(payload_prep) is
|
||||
# MAX_PAYLOAD_LENGTH - 3 + PACKET_HEADER_LENGTH = MAX_PAYLOAD_LENGTH + 1
|
||||
# surpassing the maximum allowed payload size per packet.
|
||||
offset = 0
|
||||
|
||||
# Send several MySQL packets.
|
||||
for _ in range(len(payload_prep) // MAX_PAYLOAD_LENGTH):
|
||||
await asyncio.wait_for(
|
||||
self._write_pkt(
|
||||
writer,
|
||||
address,
|
||||
payload_prep[offset : offset + MAX_PAYLOAD_LENGTH],
|
||||
),
|
||||
write_timeout,
|
||||
)
|
||||
self._set_next_compressed_pktnr()
|
||||
offset += MAX_PAYLOAD_LENGTH
|
||||
await asyncio.wait_for(
|
||||
self._write_pkt(writer, address, payload_prep[offset:]),
|
||||
write_timeout,
|
||||
)
|
||||
else:
|
||||
# Send one MySQL packet.
|
||||
# For small packets it may be too costly to compress the packet.
|
||||
# Usually payloads less than 50 bytes (MIN_COMPRESS_LENGTH) aren't
|
||||
# compressed (see MySQL source code Documentation).
|
||||
if len(payload) > MIN_COMPRESS_LENGTH:
|
||||
# Perform compression.
|
||||
await asyncio.wait_for(
|
||||
self._write_pkt(writer, address, payload_prep), write_timeout
|
||||
)
|
||||
else:
|
||||
# Skip compression.
|
||||
await asyncio.wait_for(
|
||||
super()._write_pkt(
|
||||
writer,
|
||||
address,
|
||||
struct.pack("<I", len(payload_prep))[0:3]
|
||||
+ struct.pack("<B", self._compressed_pktnr)
|
||||
+ struct.pack("<I", 0)[0:3]
|
||||
+ payload_prep,
|
||||
),
|
||||
write_timeout,
|
||||
)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError) as err:
|
||||
raise WriteTimeoutError(errno=3024) from err
|
||||
|
||||
async def _read_compressed_pkt(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
compressed_pll: int,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Handle reading of a compressed packet."""
|
||||
# compressed_pll stands for compressed payload length.
|
||||
pkt = bytearray(
|
||||
zlib.decompress(
|
||||
await super()._read_chunk(reader, compressed_pll, read_timeout)
|
||||
)
|
||||
)
|
||||
offset = 0
|
||||
while offset < len(pkt):
|
||||
# pll stands for payload length
|
||||
pll = struct.unpack(
|
||||
"<I", pkt[offset : offset + PACKET_HEADER_LENGTH - 1] + b"\x00"
|
||||
)[0]
|
||||
if PACKET_HEADER_LENGTH + pll > len(pkt) - offset:
|
||||
# More bytes need to be consumed.
|
||||
# Read the header of the next MySQL packet.
|
||||
header = await super()._read_chunk(
|
||||
reader, COMPRESSED_PACKET_HEADER_LENGTH, read_timeout
|
||||
)
|
||||
|
||||
# compressed payload length, sequence id, uncompressed payload length.
|
||||
(
|
||||
compressed_pll,
|
||||
self._compressed_pktnr,
|
||||
uncompressed_pll,
|
||||
) = self.get_header(header)
|
||||
compressed_pkt = await super()._read_chunk(
|
||||
reader, compressed_pll, read_timeout
|
||||
)
|
||||
|
||||
# Recalling that if uncompressed payload length == 0, the packet comes
|
||||
# in uncompressed, so no decompression is needed.
|
||||
pkt += (
|
||||
compressed_pkt
|
||||
if uncompressed_pll == 0
|
||||
else zlib.decompress(compressed_pkt)
|
||||
)
|
||||
|
||||
self._queue_read.append(pkt[offset : offset + PACKET_HEADER_LENGTH + pll])
|
||||
offset += PACKET_HEADER_LENGTH + pll
|
||||
|
||||
async def read(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
address: str,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> bytearray:
|
||||
"""Receive `one` or `several` packets from the MySQL server, enqueue them, and
|
||||
return the packet at the head.
|
||||
"""
|
||||
|
||||
if not self._queue_read:
|
||||
try:
|
||||
# Read the header of the next MySQL packet.
|
||||
header = await super()._read_chunk(
|
||||
reader, COMPRESSED_PACKET_HEADER_LENGTH, read_timeout
|
||||
)
|
||||
|
||||
# compressed payload length, sequence id, uncompressed payload length
|
||||
(
|
||||
compressed_pll,
|
||||
self._compressed_pktnr,
|
||||
uncompressed_pll,
|
||||
) = self.get_header(header)
|
||||
|
||||
if uncompressed_pll == 0:
|
||||
# Packet is not compressed, so just store it.
|
||||
self._queue_read.append(
|
||||
await super()._read_chunk(reader, compressed_pll, read_timeout)
|
||||
)
|
||||
else:
|
||||
# Packet comes in compressed, further action is needed.
|
||||
await self._read_compressed_pkt(
|
||||
reader, compressed_pll, read_timeout
|
||||
)
|
||||
except IOError as err:
|
||||
raise OperationalError(
|
||||
errno=2055, values=(address, _strioerror(err))
|
||||
) from err
|
||||
|
||||
if not self._queue_read:
|
||||
return None
|
||||
|
||||
pkt = self._queue_read.popleft()
|
||||
self._pktnr = pkt[3]
|
||||
|
||||
return pkt
|
||||
|
||||
|
||||
class MySQLSocket(ABC):
|
||||
"""MySQL socket communication interface.
|
||||
|
||||
Examples:
|
||||
Subclasses: network.MySQLTCPSocket and network.MySQLUnixSocket.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Network layer where transactions are made with plain (uncompressed) packets
|
||||
is enabled by default.
|
||||
"""
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._writer: Optional[StreamWriter] = None
|
||||
self._connection_timeout: Optional[int] = None
|
||||
self._address: Optional[str] = None
|
||||
self._netbroker: NetworkBroker = NetworkBrokerPlain()
|
||||
self._is_connected: bool = False
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
"""Socket location."""
|
||||
return self._address
|
||||
|
||||
@abstractmethod
|
||||
async def open_connection(self, **kwargs: Any) -> None:
|
||||
"""Open the socket."""
|
||||
|
||||
async def close_connection(self) -> None:
|
||||
"""Close the connection."""
|
||||
if self._writer:
|
||||
try:
|
||||
self._writer.close()
|
||||
# Without transport.abort(), an error is raised when using SSL
|
||||
if self._writer.transport is not None:
|
||||
self._writer.transport.abort()
|
||||
await self._writer.wait_closed()
|
||||
except Exception as _: # pylint: disable=broad-exception-caught)
|
||||
# we can ignore issues like ConnectionRefused or ConnectionAborted
|
||||
# as these instances might popup if the connection was closed due to timeout issues
|
||||
pass
|
||||
self._is_connected = False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if the socket is connected.
|
||||
|
||||
Return:
|
||||
bool: Returns `True` if the socket is connected to MySQL server.
|
||||
"""
|
||||
return self._is_connected
|
||||
|
||||
def set_connection_timeout(self, timeout: int) -> None:
|
||||
"""Set the connection timeout."""
|
||||
self._connection_timeout = timeout
|
||||
|
||||
def switch_to_compressed_mode(self) -> None:
|
||||
"""Enable network layer where transactions are made with compressed packets."""
|
||||
self._netbroker = NetworkBrokerCompressed()
|
||||
|
||||
async def switch_to_ssl(self, ssl_context: ssl.SSLContext) -> None:
|
||||
"""Upgrade an existing stream-based connection to TLS.
|
||||
|
||||
The `start_tls()` method from `asyncio.streams.StreamWriter` is only available
|
||||
in Python 3.11. This method is used as a workaround.
|
||||
|
||||
The MySQL TLS negotiation happens in the middle of the TCP connection.
|
||||
Therefore, passing a socket to open connection will cause it to negotiate
|
||||
TLS on an existing connection.
|
||||
|
||||
Args:
|
||||
ssl_context: The SSL Context to be used.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the transport does not expose the socket instance.
|
||||
"""
|
||||
# Ensure that self._writer is already created
|
||||
assert self._writer is not None
|
||||
|
||||
socket = self._writer.transport.get_extra_info("socket")
|
||||
if socket.family == 1: # socket.AF_UNIX
|
||||
raise ProgrammingError("SSL is not supported when using Unix sockets")
|
||||
|
||||
await self._writer.start_tls(ssl_context)
|
||||
|
||||
async def write(
|
||||
self,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send packets to the MySQL server."""
|
||||
await self._netbroker.write(
|
||||
self._writer,
|
||||
self.address,
|
||||
payload,
|
||||
packet_number=packet_number,
|
||||
compressed_packet_number=compressed_packet_number,
|
||||
write_timeout=write_timeout,
|
||||
)
|
||||
|
||||
async def read(self, read_timeout: Optional[int] = None) -> bytearray:
|
||||
"""Read packets from the MySQL server."""
|
||||
return await self._netbroker.read(self._reader, self.address, read_timeout)
|
||||
|
||||
def build_ssl_context(
|
||||
self,
|
||||
ssl_ca: Optional[str] = None,
|
||||
ssl_cert: Optional[str] = None,
|
||||
ssl_key: Optional[str] = None,
|
||||
ssl_verify_cert: Optional[bool] = False,
|
||||
ssl_verify_identity: Optional[bool] = False,
|
||||
tls_versions: Optional[List[str]] = [],
|
||||
tls_cipher_suites: Optional[List[str]] = [],
|
||||
) -> ssl.SSLContext:
|
||||
"""Build a SSLContext."""
|
||||
tls_version: Optional[str] = None
|
||||
|
||||
if not self._reader:
|
||||
raise InterfaceError(errno=2048)
|
||||
|
||||
if ssl is None:
|
||||
raise RuntimeError("Python installation has no SSL support")
|
||||
|
||||
try:
|
||||
if tls_versions:
|
||||
tls_versions.sort(reverse=True)
|
||||
tls_version = tls_versions[0]
|
||||
ssl_protocol = TLS_VERSIONS[tls_version]
|
||||
context = ssl.SSLContext(ssl_protocol)
|
||||
|
||||
if tls_version == "TLSv1.3":
|
||||
if "TLSv1.2" not in tls_versions:
|
||||
context.options |= ssl.OP_NO_TLSv1_2
|
||||
if "TLSv1.1" not in tls_versions:
|
||||
context.options |= ssl.OP_NO_TLSv1_1
|
||||
if "TLSv1" not in tls_versions:
|
||||
context.options |= ssl.OP_NO_TLSv1
|
||||
else:
|
||||
context = ssl.create_default_context()
|
||||
|
||||
context.check_hostname = ssl_verify_identity
|
||||
|
||||
if ssl_verify_cert:
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
elif ssl_verify_identity:
|
||||
context.verify_mode = ssl.CERT_OPTIONAL
|
||||
else:
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
context.load_default_certs()
|
||||
|
||||
if ssl_ca:
|
||||
try:
|
||||
context.load_verify_locations(ssl_ca)
|
||||
except (IOError, ssl.SSLError) as err:
|
||||
raise InterfaceError(f"Invalid CA Certificate: {err}") from err
|
||||
if ssl_cert:
|
||||
try:
|
||||
context.load_cert_chain(ssl_cert, ssl_key)
|
||||
except (IOError, ssl.SSLError) as err:
|
||||
raise InterfaceError(f"Invalid Certificate/Key: {err}") from err
|
||||
|
||||
# TLSv1.3 ciphers cannot be disabled with `SSLContext.set_ciphers(...)`,
|
||||
# see https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_ciphers.
|
||||
if tls_cipher_suites and tls_version == "TLSv1.2":
|
||||
context.set_ciphers(":".join(tls_cipher_suites))
|
||||
|
||||
return context
|
||||
except NameError as err:
|
||||
raise NotSupportedError("Python installation has no SSL support") from err
|
||||
except (
|
||||
IOError,
|
||||
NotImplementedError,
|
||||
ssl.CertificateError,
|
||||
ssl.SSLError,
|
||||
) as err:
|
||||
raise InterfaceError(str(err)) from err
|
||||
|
||||
|
||||
class MySQLTcpSocket(MySQLSocket):
|
||||
"""MySQL socket class using TCP/IP.
|
||||
|
||||
Args:
|
||||
host: MySQL host name.
|
||||
port: MySQL port.
|
||||
force_ipv6: Force IPv6 usage.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, host: str = "127.0.0.1", port: int = 3306, force_ipv6: bool = False
|
||||
):
|
||||
super().__init__()
|
||||
self._host: str = host
|
||||
self._port: int = port
|
||||
self._force_ipv6: bool = force_ipv6
|
||||
self._address: str = f"{host}:{port}"
|
||||
|
||||
async def open_connection(self, **kwargs: Any) -> None:
|
||||
"""Open TCP/IP connection."""
|
||||
self._reader, self._writer = await open_connection(
|
||||
host=self._host, port=self._port, **kwargs
|
||||
)
|
||||
self._is_connected = True
|
||||
|
||||
|
||||
class MySQLUnixSocket(MySQLSocket):
|
||||
"""MySQL socket class using UNIX sockets.
|
||||
|
||||
Args:
|
||||
unix_socket: UNIX socket file path.
|
||||
"""
|
||||
|
||||
def __init__(self, unix_socket: str = "/tmp/mysql.sock"):
|
||||
super().__init__()
|
||||
self._address: str = unix_socket
|
||||
|
||||
async def open_connection(self, **kwargs: Any) -> None:
|
||||
"""Open UNIX socket connection."""
|
||||
(
|
||||
self._reader,
|
||||
self._writer,
|
||||
) = await asyncio.open_unix_connection( # type: ignore[assignment]
|
||||
path=self._address, **kwargs
|
||||
)
|
||||
self._is_connected = True
|
||||
@@ -0,0 +1,162 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Base Authentication Plugin class."""
|
||||
|
||||
__all__ = ["MySQLAuthPlugin", "get_auth_plugin"]
|
||||
|
||||
import importlib
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING, Any, Optional, Type
|
||||
|
||||
from mysql.connector.errors import NotSupportedError, ProgrammingError
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
DEFAULT_PLUGINS_PKG = "mysql.connector.aio.plugins"
|
||||
|
||||
|
||||
class MySQLAuthPlugin(ABC):
|
||||
"""Authorization plugin interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
ssl_enabled: bool = False,
|
||||
) -> None:
|
||||
"""Constructor."""
|
||||
self._username: str = "" if username is None else username
|
||||
self._password: str = "" if password is None else password
|
||||
self._ssl_enabled: bool = ssl_enabled
|
||||
|
||||
@property
|
||||
def ssl_enabled(self) -> bool:
|
||||
"""Signals whether or not SSL is enabled."""
|
||||
return self._ssl_enabled
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
|
||||
@abstractmethod
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Make the client's authorization response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
"""
|
||||
|
||||
async def auth_more_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth more data` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Authentication method data (from a packet representing
|
||||
an `auth more data` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth communication.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth communication.
|
||||
"""
|
||||
|
||||
|
||||
@lru_cache(maxsize=10, typed=False)
|
||||
def get_auth_plugin(
|
||||
plugin_name: str,
|
||||
auth_plugin_class: Optional[str] = None,
|
||||
) -> Type[MySQLAuthPlugin]:
|
||||
"""Return authentication class based on plugin name
|
||||
|
||||
This function returns the class for the authentication plugin plugin_name.
|
||||
The returned class is a subclass of BaseAuthPlugin.
|
||||
|
||||
Args:
|
||||
plugin_name (str): Authentication plugin name.
|
||||
auth_plugin_class (str): Authentication plugin class name.
|
||||
|
||||
Raises:
|
||||
NotSupportedError: When plugin_name is not supported.
|
||||
|
||||
Returns:
|
||||
Subclass of `MySQLAuthPlugin`.
|
||||
"""
|
||||
package = DEFAULT_PLUGINS_PKG
|
||||
if plugin_name:
|
||||
try:
|
||||
logger.info("package: %s", package)
|
||||
logger.info("plugin_name: %s", plugin_name)
|
||||
plugin_module = importlib.import_module(f".{plugin_name}", package)
|
||||
if not auth_plugin_class or not hasattr(plugin_module, auth_plugin_class):
|
||||
auth_plugin_class = plugin_module.AUTHENTICATION_PLUGIN_CLASS
|
||||
logger.info("AUTHENTICATION_PLUGIN_CLASS: %s", auth_plugin_class)
|
||||
return getattr(plugin_module, auth_plugin_class)
|
||||
except ModuleNotFoundError as err:
|
||||
logger.warning("Requested Module was not found: %s", err)
|
||||
except ValueError as err:
|
||||
raise ProgrammingError(f"Invalid module name: {err}") from err
|
||||
raise NotSupportedError(f"Authentication plugin '{plugin_name}' is not supported")
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="str-bytes-safe,misc"
|
||||
|
||||
"""Kerberos Authentication Plugin."""
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import struct
|
||||
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Optional, Tuple
|
||||
|
||||
from mysql.connector.errors import InterfaceError, ProgrammingError
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
from ..authentication import ERR_STATUS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
import gssapi
|
||||
except ImportError:
|
||||
gssapi = None
|
||||
if os.name != "nt":
|
||||
raise ProgrammingError(
|
||||
"Module gssapi is required for GSSAPI authentication "
|
||||
"mechanism but was not found. Unable to authenticate "
|
||||
"with the server"
|
||||
) from None
|
||||
|
||||
try:
|
||||
import sspi
|
||||
import sspicon
|
||||
except ImportError:
|
||||
sspi = None
|
||||
sspicon = None
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = (
|
||||
"MySQLSSPIKerberosAuthPlugin" if os.name == "nt" else "MySQLKerberosAuthPlugin"
|
||||
)
|
||||
|
||||
|
||||
class MySQLBaseKerberosAuthPlugin(MySQLAuthPlugin):
|
||||
"""Base class for the MySQL Kerberos authentication plugin."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_kerberos_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def auth_continue(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge: the challenge for the negotiation.
|
||||
|
||||
Returns:
|
||||
tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
logger.debug("# auth_data: %s", auth_data)
|
||||
response = self.auth_response(auth_data, ignore_auth_data=False, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
packet = await sock.read()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
if packet != ERR_STATUS:
|
||||
rcode_size = 5 # Reader size for the response status code
|
||||
logger.debug("# Continue with GSSAPI authentication")
|
||||
logger.debug("# Response header: %s", packet[: rcode_size + 1])
|
||||
logger.debug("# Response size: %s", len(packet))
|
||||
logger.debug("# Negotiate a service request")
|
||||
complete = False
|
||||
tries = 0
|
||||
|
||||
while not complete and tries < 5:
|
||||
logger.debug("%s Attempt %s %s", "-" * 20, tries + 1, "-" * 20)
|
||||
logger.debug("<< Server response: %s", packet)
|
||||
logger.debug("# Response code: %s", packet[: rcode_size + 1])
|
||||
token, complete = self.auth_continue(packet[rcode_size:])
|
||||
if token:
|
||||
await sock.write(token)
|
||||
if complete:
|
||||
break
|
||||
packet = await sock.read()
|
||||
|
||||
logger.debug(">> Response to server: %s", token)
|
||||
tries += 1
|
||||
|
||||
if not complete:
|
||||
raise InterfaceError(
|
||||
f"Unable to fulfill server request after {tries} "
|
||||
f"attempts. Last server response: {packet}"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Last response from server: %s length: %d",
|
||||
packet,
|
||||
len(packet),
|
||||
)
|
||||
|
||||
# Receive OK packet from server.
|
||||
packet = await sock.read()
|
||||
logger.debug("<< Ok packet from server: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
|
||||
|
||||
# pylint: disable=c-extension-no-member,no-member
|
||||
class MySQLKerberosAuthPlugin(MySQLBaseKerberosAuthPlugin):
|
||||
"""Implement the MySQL Kerberos authentication plugin."""
|
||||
|
||||
context: Optional[gssapi.SecurityContext] = None
|
||||
|
||||
@staticmethod
|
||||
def get_user_from_credentials() -> str:
|
||||
"""Get user from credentials without realm."""
|
||||
try:
|
||||
creds = gssapi.Credentials(usage="initiate")
|
||||
user = str(creds.name)
|
||||
if user.find("@") != -1:
|
||||
user, _ = user.split("@", 1)
|
||||
return user
|
||||
except gssapi.raw.misc.GSSError:
|
||||
return getpass.getuser()
|
||||
|
||||
@staticmethod
|
||||
def get_store() -> dict:
|
||||
"""Get a credentials store dictionary.
|
||||
|
||||
Returns:
|
||||
dict: Credentials store dictionary with the krb5 ccache name.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If 'KRB5CCNAME' environment variable is empty.
|
||||
"""
|
||||
krb5ccname = os.environ.get(
|
||||
"KRB5CCNAME",
|
||||
(
|
||||
f"/tmp/krb5cc_{os.getuid()}"
|
||||
if os.name == "posix"
|
||||
else Path("%TEMP%").joinpath("krb5cc")
|
||||
),
|
||||
)
|
||||
if not krb5ccname:
|
||||
raise InterfaceError(
|
||||
"The 'KRB5CCNAME' environment variable is set to empty"
|
||||
)
|
||||
logger.debug("Using krb5 ccache name: FILE:%s", krb5ccname)
|
||||
store = {b"ccache": f"FILE:{krb5ccname}".encode("utf-8")}
|
||||
return store
|
||||
|
||||
def _acquire_cred_with_password(self, upn: str) -> gssapi.raw.creds.Creds:
|
||||
"""Acquire and store credentials through provided password.
|
||||
|
||||
Args:
|
||||
upn (str): User Principal Name.
|
||||
|
||||
Returns:
|
||||
gssapi.raw.creds.Creds: GSSAPI credentials.
|
||||
"""
|
||||
logger.debug("Attempt to acquire credentials through provided password")
|
||||
user = gssapi.Name(upn, gssapi.NameType.user)
|
||||
password = self._password.encode("utf-8")
|
||||
|
||||
try:
|
||||
acquire_cred_result = gssapi.raw.acquire_cred_with_password(
|
||||
user, password, usage="initiate"
|
||||
)
|
||||
creds = acquire_cred_result.creds
|
||||
gssapi.raw.store_cred_into(
|
||||
self.get_store(),
|
||||
creds=creds,
|
||||
mech=gssapi.MechType.kerberos,
|
||||
overwrite=True,
|
||||
set_default=True,
|
||||
)
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
raise ProgrammingError(
|
||||
f"Unable to acquire credentials with the given password: {err}"
|
||||
) from err
|
||||
return creds
|
||||
|
||||
@staticmethod
|
||||
def _parse_auth_data(packet: bytes) -> Tuple[str, str]:
|
||||
"""Parse authentication data.
|
||||
|
||||
Get the SPN and REALM from the authentication data packet.
|
||||
|
||||
Format:
|
||||
SPN string length two bytes <B1> <B2> +
|
||||
SPN string +
|
||||
UPN realm string length two bytes <B1> <B2> +
|
||||
UPN realm string
|
||||
|
||||
Returns:
|
||||
tuple: With 'spn' and 'realm'.
|
||||
"""
|
||||
spn_len = struct.unpack("<H", packet[:2])[0]
|
||||
packet = packet[2:]
|
||||
|
||||
spn = struct.unpack(f"<{spn_len}s", packet[:spn_len])[0]
|
||||
packet = packet[spn_len:]
|
||||
|
||||
realm_len = struct.unpack("<H", packet[:2])[0]
|
||||
realm = struct.unpack(f"<{realm_len}s", packet[2:])[0]
|
||||
|
||||
return spn.decode(), realm.decode()
|
||||
|
||||
def auth_response(
|
||||
self, auth_data: Optional[bytes] = None, **kwargs: Any
|
||||
) -> Optional[bytes]:
|
||||
"""Prepare the first message to the server."""
|
||||
spn = None
|
||||
realm = None
|
||||
|
||||
if auth_data and not kwargs.get("ignore_auth_data", True):
|
||||
try:
|
||||
spn, realm = self._parse_auth_data(auth_data)
|
||||
except struct.error as err:
|
||||
raise InterruptedError(f"Invalid authentication data: {err}") from err
|
||||
|
||||
if spn is None:
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
upn = f"{self._username}@{realm}" if self._username else None
|
||||
|
||||
logger.debug("Service Principal: %s", spn)
|
||||
logger.debug("Realm: %s", realm)
|
||||
|
||||
try:
|
||||
# Attempt to retrieve credentials from cache file
|
||||
creds: Any = gssapi.Credentials(usage="initiate")
|
||||
creds_upn = str(creds.name)
|
||||
|
||||
logger.debug("Cached credentials found")
|
||||
logger.debug("Cached credentials UPN: %s", creds_upn)
|
||||
|
||||
# Remove the realm from user
|
||||
if creds_upn.find("@") != -1:
|
||||
creds_user, creds_realm = creds_upn.split("@", 1)
|
||||
else:
|
||||
creds_user = creds_upn
|
||||
creds_realm = None
|
||||
|
||||
upn = f"{self._username}@{realm}" if self._username else creds_upn
|
||||
|
||||
# The user from cached credentials matches with the given user?
|
||||
if self._username and self._username != creds_user:
|
||||
logger.debug(
|
||||
"The user from cached credentials doesn't match with the "
|
||||
"given user"
|
||||
)
|
||||
if self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
if creds_realm and creds_realm != realm and self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
except gssapi.raw.exceptions.ExpiredCredentialsError as err:
|
||||
if upn and self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
else:
|
||||
raise InterfaceError(f"Credentials has expired: {err}") from err
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
if upn and self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
else:
|
||||
raise InterfaceError(
|
||||
f"Unable to retrieve cached credentials error: {err}"
|
||||
) from err
|
||||
|
||||
flags = (
|
||||
gssapi.RequirementFlag.mutual_authentication,
|
||||
gssapi.RequirementFlag.extended_error,
|
||||
gssapi.RequirementFlag.delegate_to_peer,
|
||||
)
|
||||
name = gssapi.Name(spn, name_type=gssapi.NameType.kerberos_principal)
|
||||
cname = name.canonicalize(gssapi.MechType.kerberos)
|
||||
self.context = gssapi.SecurityContext(
|
||||
name=cname, creds=creds, flags=sum(flags), usage="initiate"
|
||||
)
|
||||
|
||||
try:
|
||||
initial_client_token: Optional[bytes] = self.context.step()
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
raise InterfaceError(f"Unable to initiate security context: {err}") from err
|
||||
|
||||
logger.debug("Initial client token: %s", initial_client_token)
|
||||
return initial_client_token
|
||||
|
||||
def auth_continue(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge: the challenge for the negotiation.
|
||||
|
||||
Returns:
|
||||
tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
logger.debug("tgt_auth challenge: %s", tgt_auth_challenge)
|
||||
|
||||
resp: Optional[bytes] = self.context.step(tgt_auth_challenge)
|
||||
|
||||
logger.debug("Context step response: %s", resp)
|
||||
logger.debug("Context completed?: %s", self.context.complete)
|
||||
|
||||
return resp, self.context.complete
|
||||
|
||||
def auth_accept_close_handshake(self, message: bytes) -> bytes:
|
||||
"""Accept handshake and generate closing handshake message for server.
|
||||
|
||||
This method verifies the server authenticity from the given message
|
||||
and included signature and generates the closing handshake for the
|
||||
server.
|
||||
|
||||
When this method is invoked the security context is already established
|
||||
and the client and server can send GSSAPI formated secure messages.
|
||||
|
||||
To finish the authentication handshake the server sends a message
|
||||
with the security layer availability and the maximum buffer size.
|
||||
|
||||
Since the connector only uses the GSSAPI authentication mechanism to
|
||||
authenticate the user with the server, the server will verify clients
|
||||
message signature and terminate the GSSAPI authentication and send two
|
||||
messages; an authentication acceptance b'\x01\x00\x00\x08\x01' and a
|
||||
OK packet (that must be received after sent the returned message from
|
||||
this method).
|
||||
|
||||
Args:
|
||||
message: a wrapped gssapi message from the server.
|
||||
|
||||
Returns:
|
||||
bytearray (closing handshake message to be send to the server).
|
||||
"""
|
||||
if not self.context.complete:
|
||||
raise ProgrammingError("Security context is not completed")
|
||||
logger.debug("Server message: %s", message)
|
||||
logger.debug("GSSAPI flags in use: %s", self.context.actual_flags)
|
||||
try:
|
||||
unwraped = self.context.unwrap(message)
|
||||
logger.debug("Unwraped: %s", unwraped)
|
||||
except gssapi.raw.exceptions.BadMICError as err:
|
||||
logger.debug("Unable to unwrap server message: %s", err)
|
||||
raise InterfaceError(f"Unable to unwrap server message: {err}") from err
|
||||
|
||||
logger.debug("Unwrapped server message: %s", unwraped)
|
||||
# The message contents for the clients closing message:
|
||||
# - security level 1 byte, must be always 1.
|
||||
# - conciliated buffer size 3 bytes, without importance as no
|
||||
# further GSSAPI messages will be sends.
|
||||
response = bytearray(b"\x01\x00\x00\00")
|
||||
# Closing handshake must not be encrypted.
|
||||
logger.debug("Message response: %s", response)
|
||||
wraped = self.context.wrap(response, encrypt=False)
|
||||
logger.debug(
|
||||
"Wrapped message response: %s, length: %d",
|
||||
wraped[0],
|
||||
len(wraped[0]),
|
||||
)
|
||||
|
||||
return wraped.message
|
||||
|
||||
|
||||
class MySQLSSPIKerberosAuthPlugin(MySQLBaseKerberosAuthPlugin):
|
||||
"""Implement the MySQL Kerberos authentication plugin with Windows SSPI"""
|
||||
|
||||
context: Any = None
|
||||
clientauth: Any = None
|
||||
|
||||
@staticmethod
|
||||
def _parse_auth_data(packet: bytes) -> Tuple[str, str]:
|
||||
"""Parse authentication data.
|
||||
|
||||
Get the SPN and REALM from the authentication data packet.
|
||||
|
||||
Format:
|
||||
SPN string length two bytes <B1> <B2> +
|
||||
SPN string +
|
||||
UPN realm string length two bytes <B1> <B2> +
|
||||
UPN realm string
|
||||
|
||||
Returns:
|
||||
tuple: With 'spn' and 'realm'.
|
||||
"""
|
||||
spn_len = struct.unpack("<H", packet[:2])[0]
|
||||
packet = packet[2:]
|
||||
|
||||
spn = struct.unpack(f"<{spn_len}s", packet[:spn_len])[0]
|
||||
packet = packet[spn_len:]
|
||||
|
||||
realm_len = struct.unpack("<H", packet[:2])[0]
|
||||
realm = struct.unpack(f"<{realm_len}s", packet[2:])[0]
|
||||
|
||||
return spn.decode(), realm.decode()
|
||||
|
||||
def auth_response(
|
||||
self, auth_data: Optional[bytes] = None, **kwargs: Any
|
||||
) -> Optional[bytes]:
|
||||
"""Prepare the first message to the server.
|
||||
|
||||
Args:
|
||||
kwargs:
|
||||
ignore_auth_data (bool): if True, the provided auth data is ignored.
|
||||
"""
|
||||
logger.debug("auth_response for sspi")
|
||||
spn = None
|
||||
realm = None
|
||||
|
||||
if auth_data and not kwargs.get("ignore_auth_data", True):
|
||||
try:
|
||||
spn, realm = self._parse_auth_data(auth_data)
|
||||
except struct.error as err:
|
||||
raise InterruptedError(f"Invalid authentication data: {err}") from err
|
||||
|
||||
logger.debug("Service Principal: %s", spn)
|
||||
logger.debug("Realm: %s", realm)
|
||||
|
||||
if sspicon is None or sspi is None:
|
||||
raise ProgrammingError(
|
||||
'Package "pywin32" (Python for Win32 (pywin32) extensions)'
|
||||
" is not installed."
|
||||
)
|
||||
|
||||
flags = (sspicon.ISC_REQ_MUTUAL_AUTH, sspicon.ISC_REQ_DELEGATE)
|
||||
|
||||
if self._username and self._password:
|
||||
_auth_info = (self._username, realm, self._password)
|
||||
else:
|
||||
_auth_info = None
|
||||
|
||||
targetspn = spn
|
||||
logger.debug("targetspn: %s", targetspn)
|
||||
logger.debug("_auth_info is None: %s", _auth_info is None)
|
||||
|
||||
# The Security Support Provider Interface (SSPI) is an interface
|
||||
# that allows us to choose from a set of SSPs available in the
|
||||
# system; the idea of SSPI is to keep interface consistent no
|
||||
# matter what back end (a.k.a., SSP) we choose.
|
||||
|
||||
# When using SSPI we should not use Kerberos directly as SSP,
|
||||
# as remarked in [2], but we can use it indirectly via another
|
||||
# SSP named Negotiate that acts as an application layer between
|
||||
# SSPI and the other SSPs [1].
|
||||
|
||||
# Negotiate can select between Kerberos and NTLM on the fly;
|
||||
# it chooses Kerberos unless it cannot be used by one of the
|
||||
# systems involved in the authentication or the calling
|
||||
# application did not provide sufficient information to use
|
||||
# Kerberos.
|
||||
|
||||
# prefix: https://docs.microsoft.com/en-us/windows/win32/secauthn
|
||||
# [1] prefix/microsoft-negotiate?source=recommendations
|
||||
# [2] prefix/microsoft-kerberos?source=recommendations
|
||||
self.clientauth = sspi.ClientAuth(
|
||||
"Negotiate",
|
||||
targetspn=targetspn,
|
||||
auth_info=_auth_info,
|
||||
scflags=sum(flags),
|
||||
datarep=sspicon.SECURITY_NETWORK_DREP,
|
||||
)
|
||||
|
||||
try:
|
||||
data = None
|
||||
err, out_buf = self.clientauth.authorize(data)
|
||||
logger.debug("Context step err: %s", err)
|
||||
logger.debug("Context step out_buf: %s", out_buf)
|
||||
logger.debug("Context completed?: %s", self.clientauth.authenticated)
|
||||
initial_client_token = out_buf[0].Buffer
|
||||
logger.debug("pkg_info: %s", self.clientauth.pkg_info)
|
||||
except Exception as err:
|
||||
raise InterfaceError(f"Unable to initiate security context: {err}") from err
|
||||
|
||||
logger.debug("Initial client token: %s", initial_client_token)
|
||||
return initial_client_token
|
||||
|
||||
def auth_continue(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge: the challenge for the negotiation.
|
||||
|
||||
Returns:
|
||||
tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
logger.debug("tgt_auth challenge: %s", tgt_auth_challenge)
|
||||
|
||||
err, out_buf = self.clientauth.authorize(tgt_auth_challenge)
|
||||
|
||||
logger.debug("Context step err: %s", err)
|
||||
logger.debug("Context step out_buf: %s", out_buf)
|
||||
resp = out_buf[0].Buffer
|
||||
logger.debug("Context step resp: %s", resp)
|
||||
logger.debug("Context completed?: %s", self.clientauth.authenticated)
|
||||
|
||||
return resp, self.clientauth.authenticated
|
||||
+595
@@ -0,0 +1,595 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""LDAP SASL Authentication Plugin."""
|
||||
|
||||
import hmac
|
||||
|
||||
from base64 import b64decode, b64encode
|
||||
from hashlib import sha1, sha256
|
||||
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple
|
||||
from uuid import uuid4
|
||||
|
||||
from mysql.connector.authentication import ERR_STATUS
|
||||
from mysql.connector.errors import InterfaceError, ProgrammingError
|
||||
from mysql.connector.logger import logger
|
||||
from mysql.connector.types import StrOrBytes
|
||||
from mysql.connector.utils import (
|
||||
normalize_unicode_string as norm_ustr,
|
||||
validate_normalized_unicode_string as valid_norm,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
import gssapi
|
||||
except ImportError:
|
||||
raise ProgrammingError(
|
||||
"Module gssapi is required for GSSAPI authentication "
|
||||
"mechanism but was not found. Unable to authenticate "
|
||||
"with the server"
|
||||
) from None
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLLdapSaslPasswordAuthPlugin"
|
||||
|
||||
|
||||
# pylint: disable=c-extension-no-member,no-member
|
||||
class MySQLLdapSaslPasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL ldap sasl authentication plugin.
|
||||
|
||||
The MySQL's ldap sasl authentication plugin support two authentication
|
||||
methods SCRAM-SHA-1 and GSSAPI (using Kerberos). This implementation only
|
||||
support SCRAM-SHA-1 and SCRAM-SHA-256.
|
||||
|
||||
SCRAM-SHA-1 amd SCRAM-SHA-256
|
||||
This method requires 2 messages from client and 2 responses from
|
||||
server.
|
||||
|
||||
The first message from client will be generated by prepare_password(),
|
||||
after receive the response from the server, it is required that this
|
||||
response is passed back to auth_continue() which will return the
|
||||
second message from the client. After send this second message to the
|
||||
server, the second server respond needs to be passed to auth_finalize()
|
||||
to finish the authentication process.
|
||||
"""
|
||||
|
||||
sasl_mechanisms: List[str] = ["SCRAM-SHA-1", "SCRAM-SHA-256", "GSSAPI"]
|
||||
def_digest_mode: Callable = sha1
|
||||
client_nonce: Optional[str] = None
|
||||
client_salt: Any = None
|
||||
server_salt: Optional[str] = None
|
||||
krb_service_principal: Optional[str] = None
|
||||
iterations: int = 0
|
||||
server_auth_var: Optional[str] = None
|
||||
target_name: Optional[gssapi.Name] = None
|
||||
ctx: gssapi.SecurityContext = None
|
||||
servers_first: Optional[str] = None
|
||||
server_nonce: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def _xor(bytes1: bytes, bytes2: bytes) -> bytes:
|
||||
return bytes([b1 ^ b2 for b1, b2 in zip(bytes1, bytes2)])
|
||||
|
||||
def _hmac(self, password: bytes, salt: bytes) -> bytes:
|
||||
digest_maker = hmac.new(password, salt, self.def_digest_mode)
|
||||
return digest_maker.digest()
|
||||
|
||||
def _hi(self, password: str, salt: bytes, count: int) -> bytes:
|
||||
"""Prepares Hi
|
||||
Hi(password, salt, iterations) where Hi(p,s,i) is defined as
|
||||
PBKDF2 (HMAC, p, s, i, output length of H).
|
||||
"""
|
||||
pw = password.encode()
|
||||
hi = self._hmac(pw, salt + b"\x00\x00\x00\x01")
|
||||
aux = hi
|
||||
for _ in range(count - 1):
|
||||
aux = self._hmac(pw, aux)
|
||||
hi = self._xor(hi, aux)
|
||||
return hi
|
||||
|
||||
@staticmethod
|
||||
def _normalize(string: str) -> str:
|
||||
norm_str = norm_ustr(string)
|
||||
broken_rule = valid_norm(norm_str)
|
||||
if broken_rule is not None:
|
||||
raise InterfaceError(f"broken_rule: {broken_rule}")
|
||||
return norm_str
|
||||
|
||||
def _first_message(self) -> bytes:
|
||||
"""This method generates the first message to the server to start the
|
||||
|
||||
The client-first message consists of a gs2-header,
|
||||
the desired username, and a randomly generated client nonce cnonce.
|
||||
|
||||
The first message from the server has the form:
|
||||
b'n,a=<user_name>,n=<user_name>,r=<client_nonce>
|
||||
|
||||
Returns client's first message
|
||||
"""
|
||||
cfm_fprnat = "n,a={user_name},n={user_name},r={client_nonce}"
|
||||
self.client_nonce = str(uuid4()).replace("-", "")
|
||||
cfm: StrOrBytes = cfm_fprnat.format(
|
||||
user_name=self._normalize(self._username),
|
||||
client_nonce=self.client_nonce,
|
||||
)
|
||||
|
||||
if isinstance(cfm, str):
|
||||
cfm = cfm.encode("utf8")
|
||||
return cfm
|
||||
|
||||
def _first_message_krb(self) -> Optional[bytes]:
|
||||
"""Get a TGT Authentication request and initiates security context.
|
||||
|
||||
This method will contact the Kerberos KDC in order of obtain a TGT.
|
||||
"""
|
||||
user_name = gssapi.raw.names.import_name(
|
||||
self._username.encode("utf8"), name_type=gssapi.NameType.user
|
||||
)
|
||||
|
||||
# Use defaults store = {'ccache': 'FILE:/tmp/krb5cc_1000'}#,
|
||||
# 'keytab':'/etc/some.keytab' }
|
||||
# Attempt to retrieve credential from default cache file.
|
||||
try:
|
||||
cred: Any = gssapi.Credentials()
|
||||
logger.debug(
|
||||
"# Stored credentials found, if password was given it will be ignored."
|
||||
)
|
||||
try:
|
||||
# validate credentials has not expired.
|
||||
cred.lifetime
|
||||
except gssapi.raw.exceptions.ExpiredCredentialsError as err:
|
||||
logger.warning(" Credentials has expired: %s", err)
|
||||
cred.acquire(user_name)
|
||||
raise InterfaceError(f"Credentials has expired: {err}") from err
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
if not self._password:
|
||||
raise InterfaceError(
|
||||
f"Unable to retrieve stored credentials error: {err}"
|
||||
) from err
|
||||
try:
|
||||
logger.debug("# Attempt to retrieve credentials with given password")
|
||||
acquire_cred_result = gssapi.raw.acquire_cred_with_password(
|
||||
user_name,
|
||||
self._password.encode("utf8"),
|
||||
usage="initiate",
|
||||
)
|
||||
cred = acquire_cred_result[0]
|
||||
except gssapi.raw.misc.GSSError as err2:
|
||||
raise ProgrammingError(
|
||||
f"Unable to retrieve credentials with the given password: {err2}"
|
||||
) from err
|
||||
|
||||
flags_l = (
|
||||
gssapi.RequirementFlag.mutual_authentication,
|
||||
gssapi.RequirementFlag.extended_error,
|
||||
gssapi.RequirementFlag.delegate_to_peer,
|
||||
)
|
||||
|
||||
if self.krb_service_principal:
|
||||
service_principal = self.krb_service_principal
|
||||
else:
|
||||
service_principal = "ldap/ldapauth"
|
||||
logger.debug("# service principal: %s", service_principal)
|
||||
servk = gssapi.Name(
|
||||
service_principal, name_type=gssapi.NameType.kerberos_principal
|
||||
)
|
||||
self.target_name = servk
|
||||
self.ctx = gssapi.SecurityContext(
|
||||
name=servk, creds=cred, flags=sum(flags_l), usage="initiate"
|
||||
)
|
||||
|
||||
try:
|
||||
# step() returns bytes | None, see documentation,
|
||||
# so this method could return a NULL payload.
|
||||
# ref: https://pythongssapi.github.io/<suffix>
|
||||
# suffix: python-gssapi/latest/gssapi.html#gssapi.sec_contexts.SecurityContext
|
||||
initial_client_token = self.ctx.step()
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
raise InterfaceError(f"Unable to initiate security context: {err}") from err
|
||||
|
||||
logger.debug("# initial client token: %s", initial_client_token)
|
||||
return initial_client_token
|
||||
|
||||
def auth_continue_krb(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge the challenge for the negotiation.
|
||||
|
||||
Returns: tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
logger.debug("tgt_auth challenge: %s", tgt_auth_challenge)
|
||||
|
||||
resp = self.ctx.step(tgt_auth_challenge)
|
||||
logger.debug("# context step response: %s", resp)
|
||||
logger.debug("# context completed?: %s", self.ctx.complete)
|
||||
|
||||
return resp, self.ctx.complete
|
||||
|
||||
def auth_accept_close_handshake(self, message: bytes) -> bytes:
|
||||
"""Accept handshake and generate closing handshake message for server.
|
||||
|
||||
This method verifies the server authenticity from the given message
|
||||
and included signature and generates the closing handshake for the
|
||||
server.
|
||||
|
||||
When this method is invoked the security context is already established
|
||||
and the client and server can send GSSAPI formated secure messages.
|
||||
|
||||
To finish the authentication handshake the server sends a message
|
||||
with the security layer availability and the maximum buffer size.
|
||||
|
||||
Since the connector only uses the GSSAPI authentication mechanism to
|
||||
authenticate the user with the server, the server will verify clients
|
||||
message signature and terminate the GSSAPI authentication and send two
|
||||
messages; an authentication acceptance b'\x01\x00\x00\x08\x01' and a
|
||||
OK packet (that must be received after sent the returned message from
|
||||
this method).
|
||||
|
||||
Args:
|
||||
message a wrapped hssapi message from the server.
|
||||
|
||||
Returns: bytearray closing handshake message to be send to the server.
|
||||
"""
|
||||
if not self.ctx.complete:
|
||||
raise ProgrammingError("Security context is not completed.")
|
||||
logger.debug("# servers message: %s", message)
|
||||
logger.debug("# GSSAPI flags in use: %s", self.ctx.actual_flags)
|
||||
try:
|
||||
unwraped = self.ctx.unwrap(message)
|
||||
logger.debug("# unwraped: %s", unwraped)
|
||||
except gssapi.raw.exceptions.BadMICError as err:
|
||||
raise InterfaceError(f"Unable to unwrap server message: {err}") from err
|
||||
|
||||
logger.debug("# unwrapped server message: %s", unwraped)
|
||||
# The message contents for the clients closing message:
|
||||
# - security level 1 byte, must be always 1.
|
||||
# - conciliated buffer size 3 bytes, without importance as no
|
||||
# further GSSAPI messages will be sends.
|
||||
response = bytearray(b"\x01\x00\x00\00")
|
||||
# Closing handshake must not be encrypted.
|
||||
logger.debug("# message response: %s", response)
|
||||
wraped = self.ctx.wrap(response, encrypt=False)
|
||||
logger.debug(
|
||||
"# wrapped message response: %s, length: %d",
|
||||
wraped[0],
|
||||
len(wraped[0]),
|
||||
)
|
||||
|
||||
return wraped.message
|
||||
|
||||
def auth_response(
|
||||
self,
|
||||
auth_data: bytes,
|
||||
**kwargs: Any,
|
||||
) -> Optional[bytes]:
|
||||
"""This method will prepare the fist message to the server.
|
||||
|
||||
Returns bytes to send to the server as the first message.
|
||||
"""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self._auth_data = auth_data
|
||||
|
||||
auth_mechanism = self._auth_data.decode()
|
||||
logger.debug("read_method_name_from_server: %s", auth_mechanism)
|
||||
if auth_mechanism not in self.sasl_mechanisms:
|
||||
auth_mechanisms = '", "'.join(self.sasl_mechanisms[:-1])
|
||||
raise InterfaceError(
|
||||
f'The sasl authentication method "{auth_mechanism}" requested '
|
||||
f'from the server is not supported. Only "{auth_mechanisms}" '
|
||||
f'and "{self.sasl_mechanisms[-1]}" are supported'
|
||||
)
|
||||
|
||||
if b"GSSAPI" in self._auth_data:
|
||||
return self._first_message_krb()
|
||||
|
||||
if self._auth_data == b"SCRAM-SHA-256":
|
||||
self.def_digest_mode = sha256
|
||||
|
||||
return self._first_message()
|
||||
|
||||
def _second_message(self) -> bytes:
|
||||
"""This method generates the second message to the server
|
||||
|
||||
Second message consist on the concatenation of the client and the
|
||||
server nonce, and cproof.
|
||||
|
||||
c=<n,a=<user_name>>,r=<server_nonce>,p=<client_proof>
|
||||
where:
|
||||
<client_proof>: xor(<client_key>, <client_signature>)
|
||||
|
||||
<client_key>: hmac(salted_password, b"Client Key")
|
||||
<client_signature>: hmac(<stored_key>, <auth_msg>)
|
||||
<stored_key>: h(<client_key>)
|
||||
<auth_msg>: <client_first_no_header>,<servers_first>,
|
||||
c=<client_header>,r=<server_nonce>
|
||||
<client_first_no_header>: n=<username>r=<client_nonce>
|
||||
"""
|
||||
if not self._auth_data:
|
||||
raise InterfaceError("Missing authentication data (seed)")
|
||||
|
||||
passw = self._normalize(self._password)
|
||||
salted_password = self._hi(passw, b64decode(self.server_salt), self.iterations)
|
||||
logger.debug("salted_password: %s", b64encode(salted_password).decode())
|
||||
|
||||
client_key = self._hmac(salted_password, b"Client Key")
|
||||
logger.debug("client_key: %s", b64encode(client_key).decode())
|
||||
|
||||
stored_key = self.def_digest_mode(client_key).digest()
|
||||
logger.debug("stored_key: %s", b64encode(stored_key).decode())
|
||||
|
||||
server_key = self._hmac(salted_password, b"Server Key")
|
||||
logger.debug("server_key: %s", b64encode(server_key).decode())
|
||||
|
||||
client_first_no_header = ",".join(
|
||||
[
|
||||
f"n={self._normalize(self._username)}",
|
||||
f"r={self.client_nonce}",
|
||||
]
|
||||
)
|
||||
logger.debug("client_first_no_header: %s", client_first_no_header)
|
||||
|
||||
client_header = b64encode(
|
||||
f"n,a={self._normalize(self._username)},".encode()
|
||||
).decode()
|
||||
|
||||
auth_msg = ",".join(
|
||||
[
|
||||
client_first_no_header,
|
||||
self.servers_first,
|
||||
f"c={client_header}",
|
||||
f"r={self.server_nonce}",
|
||||
]
|
||||
)
|
||||
logger.debug("auth_msg: %s", auth_msg)
|
||||
|
||||
client_signature = self._hmac(stored_key, auth_msg.encode())
|
||||
logger.debug("client_signature: %s", b64encode(client_signature).decode())
|
||||
|
||||
client_proof = self._xor(client_key, client_signature)
|
||||
logger.debug("client_proof: %s", b64encode(client_proof).decode())
|
||||
|
||||
self.server_auth_var = b64encode(
|
||||
self._hmac(server_key, auth_msg.encode())
|
||||
).decode()
|
||||
logger.debug("server_auth_var: %s", self.server_auth_var)
|
||||
|
||||
msg = ",".join(
|
||||
[
|
||||
f"c={client_header}",
|
||||
f"r={self.server_nonce}",
|
||||
f"p={b64encode(client_proof).decode()}",
|
||||
]
|
||||
)
|
||||
logger.debug("second_message: %s", msg)
|
||||
return msg.encode()
|
||||
|
||||
def _validate_first_reponse(self, servers_first: bytes) -> None:
|
||||
"""Validates first message from the server.
|
||||
|
||||
Extracts the server's salt and iterations from the servers 1st response.
|
||||
First message from the server is in the form:
|
||||
<server_salt>,i=<iterations>
|
||||
"""
|
||||
if not servers_first or not isinstance(servers_first, (bytearray, bytes)):
|
||||
raise InterfaceError(f"Unexpected server message: {repr(servers_first)}")
|
||||
try:
|
||||
servers_first_str = servers_first.decode()
|
||||
self.servers_first = servers_first_str
|
||||
r_server_nonce, s_salt, i_counter = servers_first_str.split(",")
|
||||
except ValueError:
|
||||
raise InterfaceError(
|
||||
f"Unexpected server message: {servers_first_str}"
|
||||
) from None
|
||||
if (
|
||||
not r_server_nonce.startswith("r=")
|
||||
or not s_salt.startswith("s=")
|
||||
or not i_counter.startswith("i=")
|
||||
):
|
||||
raise InterfaceError(
|
||||
f"Incomplete reponse from the server: {servers_first_str}"
|
||||
)
|
||||
if self.client_nonce in r_server_nonce:
|
||||
self.server_nonce = r_server_nonce[2:]
|
||||
logger.debug("server_nonce: %s", self.server_nonce)
|
||||
else:
|
||||
raise InterfaceError(
|
||||
"Unable to authenticate response: response not well formed "
|
||||
f"{servers_first_str}"
|
||||
)
|
||||
self.server_salt = s_salt[2:]
|
||||
logger.debug(
|
||||
"server_salt: %s length: %s",
|
||||
self.server_salt,
|
||||
len(self.server_salt),
|
||||
)
|
||||
try:
|
||||
i_counter = i_counter[2:]
|
||||
logger.debug("iterations: %s", i_counter)
|
||||
self.iterations = int(i_counter)
|
||||
except Exception as err:
|
||||
raise InterfaceError(
|
||||
f"Unable to authenticate: iterations not found {servers_first_str}"
|
||||
) from err
|
||||
|
||||
def auth_continue(self, servers_first_response: bytes) -> bytes:
|
||||
"""return the second message from the client.
|
||||
|
||||
Returns bytes to send to the server as the second message.
|
||||
"""
|
||||
self._validate_first_reponse(servers_first_response)
|
||||
return self._second_message()
|
||||
|
||||
def _validate_second_reponse(self, servers_second: bytearray) -> bool:
|
||||
"""Validates second message from the server.
|
||||
|
||||
The client and the server prove to each other they have the same Auth
|
||||
variable.
|
||||
|
||||
The second message from the server consist of the server's proof:
|
||||
server_proof = HMAC(<server_key>, <auth_msg>)
|
||||
where:
|
||||
<server_key>: hmac(<salted_password>, b"Server Key")
|
||||
<auth_msg>: <client_first_no_header>,<servers_first>,
|
||||
c=<client_header>,r=<server_nonce>
|
||||
|
||||
Our server_proof must be equal to the Auth variable send on this second
|
||||
response.
|
||||
"""
|
||||
if (
|
||||
not servers_second
|
||||
or not isinstance(servers_second, bytearray)
|
||||
or len(servers_second) <= 2
|
||||
or not servers_second.startswith(b"v=")
|
||||
):
|
||||
raise InterfaceError("The server's proof is not well formated")
|
||||
server_var = servers_second[2:].decode()
|
||||
logger.debug("server auth variable: %s", server_var)
|
||||
return self.server_auth_var == server_var
|
||||
|
||||
def auth_finalize(self, servers_second_response: bytearray) -> bool:
|
||||
"""finalize the authentication process.
|
||||
|
||||
Raises InterfaceError if the ervers_second_response is invalid.
|
||||
|
||||
Returns True in successful authentication False otherwise.
|
||||
"""
|
||||
if not self._validate_second_reponse(servers_second_response):
|
||||
raise InterfaceError(
|
||||
"Authentication failed: Unable to proof server identity"
|
||||
)
|
||||
return True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_ldap_sasl_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
logger.debug("# auth_data: %s", auth_data)
|
||||
self.krb_service_principal = kwargs.get("krb_service_principal")
|
||||
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
packet = await sock.read()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
if len(packet) >= 6 and packet[5] == 114 and packet[6] == 61: # 'r' and '='
|
||||
# Continue with sasl authentication
|
||||
dec_response = packet[5:]
|
||||
cresponse = self.auth_continue(dec_response)
|
||||
await sock.write(cresponse)
|
||||
packet = await sock.read()
|
||||
if packet[5] == 118 and packet[6] == 61: # 'v' and '='
|
||||
if self.auth_finalize(packet[5:]):
|
||||
# receive packed OK
|
||||
packet = await sock.read()
|
||||
elif auth_data == b"GSSAPI" and packet[4] != ERR_STATUS:
|
||||
rcode_size = 5 # header size for the response status code.
|
||||
logger.debug("# Continue with sasl GSSAPI authentication")
|
||||
logger.debug("# response header: %s", packet[: rcode_size + 1])
|
||||
logger.debug("# response size: %s", len(packet))
|
||||
|
||||
logger.debug("# Negotiate a service request")
|
||||
complete = False
|
||||
tries = 0 # To avoid a infinite loop attempt no more than feedback messages
|
||||
while not complete and tries < 5:
|
||||
logger.debug("%s Attempt %s %s", "-" * 20, tries + 1, "-" * 20)
|
||||
logger.debug("<< server response: %s", packet)
|
||||
logger.debug("# response code: %s", packet[: rcode_size + 1])
|
||||
step, complete = self.auth_continue_krb(packet[rcode_size:])
|
||||
logger.debug(" >> response to server: %s", step)
|
||||
await sock.write(step or b"")
|
||||
packet = await sock.read()
|
||||
tries += 1
|
||||
if not complete:
|
||||
raise InterfaceError(
|
||||
f"Unable to fulfill server request after {tries} "
|
||||
f"attempts. Last server response: {packet}"
|
||||
)
|
||||
logger.debug(
|
||||
" last GSSAPI response from server: %s length: %d",
|
||||
packet,
|
||||
len(packet),
|
||||
)
|
||||
last_step = self.auth_accept_close_handshake(packet[rcode_size:])
|
||||
logger.debug(
|
||||
" >> last response to server: %s length: %d",
|
||||
last_step,
|
||||
len(last_step),
|
||||
)
|
||||
await sock.write(last_step)
|
||||
# Receive final handshake from server
|
||||
packet = await sock.read()
|
||||
logger.debug("<< final handshake from server: %s", packet)
|
||||
|
||||
# receive OK packet from server.
|
||||
packet = await sock.read()
|
||||
logger.debug("<< ok packet from server: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
|
||||
|
||||
# pylint: enable=c-extension-no-member,no-member
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="arg-type,union-attr,call-arg"
|
||||
|
||||
"""OCI Authentication Plugin."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from mysql.connector import errors
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
from cryptography.exceptions import UnsupportedAlgorithm
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.asymmetric.types import PRIVATE_KEY_TYPES
|
||||
except ImportError:
|
||||
raise errors.ProgrammingError("Package 'cryptography' is not installed") from None
|
||||
|
||||
try:
|
||||
from oci import config, exceptions
|
||||
except ImportError:
|
||||
raise errors.ProgrammingError(
|
||||
"Package 'oci' (Oracle Cloud Infrastructure Python SDK) is not installed"
|
||||
) from None
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLOCIAuthPlugin"
|
||||
OCI_SECURITY_TOKEN_MAX_SIZE = 10 * 1024 # In bytes
|
||||
OCI_SECURITY_TOKEN_TOO_LARGE = "Ephemeral security token is too large (10KB max)"
|
||||
OCI_SECURITY_TOKEN_FILE_NOT_AVAILABLE = (
|
||||
"Ephemeral security token file ('security_token_file') could not be read"
|
||||
)
|
||||
OCI_PROFILE_MISSING_PROPERTIES = (
|
||||
"OCI configuration file does not contain a 'fingerprint' or 'key_file' entry"
|
||||
)
|
||||
|
||||
|
||||
class MySQLOCIAuthPlugin(MySQLAuthPlugin):
|
||||
"""Implement the MySQL OCI IAM authentication plugin."""
|
||||
|
||||
context: Any = None
|
||||
oci_config_profile: str = "DEFAULT"
|
||||
oci_config_file: str = config.DEFAULT_LOCATION
|
||||
|
||||
@staticmethod
|
||||
def _prepare_auth_response(signature: bytes, oci_config: Dict[str, Any]) -> str:
|
||||
"""Prepare client's authentication response
|
||||
|
||||
Prepares client's authentication response in JSON format
|
||||
Args:
|
||||
signature (bytes): server's nonce to be signed by client.
|
||||
oci_config (dict): OCI configuration object.
|
||||
|
||||
Returns:
|
||||
str: JSON string with the following format:
|
||||
{"fingerprint": str, "signature": str, "token": base64.base64.base64}
|
||||
|
||||
Raises:
|
||||
ProgrammingError: If the ephemeral security token file can't be open or the
|
||||
token is too large.
|
||||
"""
|
||||
signature_64 = b64encode(signature)
|
||||
auth_response = {
|
||||
"fingerprint": oci_config["fingerprint"],
|
||||
"signature": signature_64.decode(),
|
||||
}
|
||||
|
||||
# The security token, if it exists, should be a JWT (JSON Web Token), consisted
|
||||
# of a base64-encoded header, body, and signature, separated by '.',
|
||||
# e.g. "Base64.Base64.Base64", stored in a file at the path specified by the
|
||||
# security_token_file configuration property
|
||||
if oci_config.get("security_token_file"):
|
||||
try:
|
||||
security_token_file = Path(oci_config["security_token_file"])
|
||||
# Check if token exceeds the maximum size
|
||||
if security_token_file.stat().st_size > OCI_SECURITY_TOKEN_MAX_SIZE:
|
||||
raise errors.ProgrammingError(OCI_SECURITY_TOKEN_TOO_LARGE)
|
||||
auth_response["token"] = security_token_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as err:
|
||||
raise errors.ProgrammingError(
|
||||
OCI_SECURITY_TOKEN_FILE_NOT_AVAILABLE
|
||||
) from err
|
||||
return json.dumps(auth_response, separators=(",", ":"))
|
||||
|
||||
@staticmethod
|
||||
def _get_private_key(key_path: str) -> PRIVATE_KEY_TYPES:
|
||||
"""Get the private_key form the given location"""
|
||||
try:
|
||||
with open(os.path.expanduser(key_path), "rb") as key_file:
|
||||
private_key = serialization.load_pem_private_key(
|
||||
key_file.read(),
|
||||
password=None,
|
||||
)
|
||||
except (TypeError, OSError, ValueError, UnsupportedAlgorithm) as err:
|
||||
raise errors.ProgrammingError(
|
||||
"An error occurred while reading the API_KEY from "
|
||||
f'"{key_path}": {err}'
|
||||
)
|
||||
|
||||
return private_key
|
||||
|
||||
def _get_valid_oci_config(self) -> Dict[str, Any]:
|
||||
"""Get a valid OCI config from the given configuration file path"""
|
||||
error_list = []
|
||||
req_keys = {
|
||||
"fingerprint": (lambda x: len(x) > 32),
|
||||
"key_file": (lambda x: os.path.exists(os.path.expanduser(x))),
|
||||
}
|
||||
|
||||
oci_config: Dict[str, Any] = {}
|
||||
try:
|
||||
# key_file is validated by oci.config if present
|
||||
oci_config = config.from_file(
|
||||
self.oci_config_file or config.DEFAULT_LOCATION,
|
||||
self.oci_config_profile or "DEFAULT",
|
||||
)
|
||||
for req_key, req_value in req_keys.items():
|
||||
try:
|
||||
# Verify parameter in req_key is present and valid
|
||||
if oci_config[req_key] and not req_value(oci_config[req_key]):
|
||||
error_list.append(f'Parameter "{req_key}" is invalid')
|
||||
except KeyError:
|
||||
error_list.append(f"Does not contain parameter {req_key}")
|
||||
except (
|
||||
exceptions.ConfigFileNotFound,
|
||||
exceptions.InvalidConfig,
|
||||
exceptions.InvalidKeyFilePath,
|
||||
exceptions.InvalidPrivateKey,
|
||||
exceptions.ProfileNotFound,
|
||||
) as err:
|
||||
error_list.append(str(err))
|
||||
|
||||
# Raise errors if any
|
||||
if error_list:
|
||||
raise errors.ProgrammingError(
|
||||
f"Invalid oci-config-file: {self.oci_config_file}. "
|
||||
f"Errors found: {error_list}"
|
||||
)
|
||||
|
||||
return oci_config
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_oci_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Prepare authentication string for the server."""
|
||||
logger.debug("server nonce: %s, len %d", auth_data, len(auth_data))
|
||||
|
||||
oci_config = self._get_valid_oci_config()
|
||||
|
||||
private_key = self._get_private_key(oci_config["key_file"])
|
||||
signature = private_key.sign(auth_data, padding.PKCS1v15(), hashes.SHA256())
|
||||
|
||||
auth_response = self._prepare_auth_response(signature, oci_config)
|
||||
logger.debug("authentication response: %s", auth_response)
|
||||
return auth_response.encode()
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
self.oci_config_file = kwargs.get("oci_config_file", "DEFAULT")
|
||||
self.oci_config_profile = kwargs.get(
|
||||
"oci_config_profile", config.DEFAULT_LOCATION
|
||||
)
|
||||
logger.debug("# oci configuration file path: %s", self.oci_config_file)
|
||||
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
packet = await sock.read()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# Copyright (c) 2024, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""OpenID Authentication Plugin."""
|
||||
|
||||
import re
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
from mysql.connector import errors, utils
|
||||
from mysql.connector.aio.network import MySQLSocket
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLOpenIDConnectAuthPlugin"
|
||||
OPENID_TOKEN_MAX_SIZE = 10 * 1024 # In bytes
|
||||
|
||||
|
||||
class MySQLOpenIDConnectAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL OpenID Connect Authentication Plugin."""
|
||||
|
||||
_openid_capability_flag: bytes = utils.int1store(1)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_openid_connect_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _validate_openid_token(token: str) -> bool:
|
||||
"""Helper method used to validate OpenID Connect token
|
||||
|
||||
The Token is represented as a JSON Web Token (JWT) consists of a
|
||||
base64-encoded header, body, and signature, separated by '.' e.g.,
|
||||
"Base64url.Base64url.Base64url". The First part of the token contains
|
||||
the header, the second part contains payload and the third part contains
|
||||
signature. These token parts should be Base64 URLSafe i.e., Token cannot
|
||||
contain characters other than a-z, A-Z, 0-9 and special characters '-', '_'.
|
||||
|
||||
Args:
|
||||
token (str): Base64url-encoded OpenID connect token fetched from
|
||||
the file path passed via `openid_token_file` connection
|
||||
argument.
|
||||
|
||||
Returns:
|
||||
bool: Signal indicating whether the token is valid or not.
|
||||
"""
|
||||
header_payload_sig: List[str] = token.split(".")
|
||||
if len(header_payload_sig) != 3:
|
||||
# invalid structure
|
||||
return False
|
||||
urlsafe_pattern = re.compile("^[a-zA-Z0-9-_]*$")
|
||||
return all(
|
||||
(
|
||||
len(token_part) and urlsafe_pattern.search(token_part) is not None
|
||||
for token_part in header_payload_sig
|
||||
)
|
||||
)
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> bytes:
|
||||
"""Prepares authentication string for the server.
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
The OpenID Connect authorization response follows the pattern :-
|
||||
int<1> capability flag
|
||||
string<lenenc> id token
|
||||
|
||||
Raises:
|
||||
InterfaceError: If the connection is insecure or the OpenID Token is too large,
|
||||
invalid or non-existent.
|
||||
ProgrammingError: If the OpenID Token file could not be read.
|
||||
"""
|
||||
try:
|
||||
# Check if the connection is secure
|
||||
if self.requires_ssl and not self._ssl_enabled:
|
||||
raise errors.InterfaceError(f"{self.name} requires SSL")
|
||||
|
||||
# Validate the file
|
||||
token_file_path: str = kwargs.get("openid_token_file", None)
|
||||
openid_token_file: Path = Path(token_file_path)
|
||||
# Check if token exceeds the maximum size
|
||||
if openid_token_file.stat().st_size > OPENID_TOKEN_MAX_SIZE:
|
||||
raise errors.InterfaceError(
|
||||
"The OpenID Connect token file size is too large (> 10KB)"
|
||||
)
|
||||
openid_token: str = openid_token_file.read_text(encoding="utf-8")
|
||||
openid_token = openid_token.strip()
|
||||
# Validate the JWT Token
|
||||
if not self._validate_openid_token(openid_token):
|
||||
raise errors.InterfaceError("The OpenID Connect Token is invalid")
|
||||
|
||||
# build the auth_response packet
|
||||
auth_response: List[bytes] = [
|
||||
self._openid_capability_flag,
|
||||
utils.lc_int(len(openid_token)),
|
||||
openid_token.encode(),
|
||||
]
|
||||
return b"".join(auth_response)
|
||||
except (SyntaxError, TypeError, OSError, UnicodeError) as err:
|
||||
raise errors.ProgrammingError(
|
||||
"The OpenID Connect Token File (openid_token_file) could not be read"
|
||||
) from err
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: MySQLSocket, auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If a NULL auth response is received from auth_response method.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
packet = await sock.read()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""WebAuthn Authentication Plugin."""
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
from mysql.connector import errors, utils
|
||||
|
||||
from ..logger import logger
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
from fido2.cbor import dump_bytes as cbor_dump_bytes
|
||||
from fido2.client import Fido2Client, UserInteraction
|
||||
from fido2.hid import CtapHidDevice
|
||||
from fido2.webauthn import PublicKeyCredentialRequestOptions
|
||||
except ImportError as import_err:
|
||||
raise errors.ProgrammingError(
|
||||
"Module fido2 is required for WebAuthn authentication mechanism but was "
|
||||
"not found. Unable to authenticate with the server"
|
||||
) from import_err
|
||||
|
||||
try:
|
||||
from fido2.pcsc import CtapPcscDevice
|
||||
|
||||
CTAP_PCSC_DEVICE_AVAILABLE = True
|
||||
except ModuleNotFoundError:
|
||||
CTAP_PCSC_DEVICE_AVAILABLE = False
|
||||
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLWebAuthnAuthPlugin"
|
||||
|
||||
|
||||
class ClientInteraction(UserInteraction):
|
||||
"""Provides user interaction to the Client."""
|
||||
|
||||
def __init__(self, callback: Optional[Callable] = None):
|
||||
self.callback = callback
|
||||
self.msg = (
|
||||
"Please insert FIDO device and perform gesture action for authentication "
|
||||
"to complete."
|
||||
)
|
||||
|
||||
def prompt_up(self) -> None:
|
||||
"""Prompt message for the user interaction with the FIDO device."""
|
||||
if self.callback is None:
|
||||
print(self.msg)
|
||||
else:
|
||||
self.callback(self.msg)
|
||||
|
||||
|
||||
class MySQLWebAuthnAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL WebAuthn authentication plugin."""
|
||||
|
||||
client: Optional[Fido2Client] = None
|
||||
callback: Optional[Callable] = None
|
||||
options: dict = {"rpId": None, "challenge": None, "allowCredentials": []}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_webauthn_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def get_assertion_response(
|
||||
self, credential_id: Optional[bytearray] = None
|
||||
) -> bytes:
|
||||
"""Get assertion from authenticator and return the response.
|
||||
|
||||
Args:
|
||||
credential_id (Optional[bytearray]): The credential ID.
|
||||
|
||||
Returns:
|
||||
bytearray: The response packet with the data from the assertion.
|
||||
"""
|
||||
if self.client is None:
|
||||
raise errors.InterfaceError("No WebAuthn client found")
|
||||
|
||||
if credential_id is not None:
|
||||
# If credential_id is not None, it's because the FIDO device does not
|
||||
# support resident keys and the credential_id was requested from the server
|
||||
self.options["allowCredentials"] = [
|
||||
{
|
||||
"id": credential_id,
|
||||
"type": "public-key",
|
||||
}
|
||||
]
|
||||
|
||||
# Get assertion from authenticator
|
||||
assertion = self.client.get_assertion(
|
||||
PublicKeyCredentialRequestOptions.from_dict(self.options)
|
||||
)
|
||||
number_of_assertions = len(assertion.get_assertions())
|
||||
client_data_json = b""
|
||||
|
||||
# Build response packet
|
||||
#
|
||||
# Format:
|
||||
# int<1> 0x02 (2) status tag
|
||||
# int<lenenc> number of assertions length encoded number of assertions
|
||||
# string authenticator data variable length raw binary string
|
||||
# string signed challenge variable length raw binary string
|
||||
# ...
|
||||
# ...
|
||||
# string authenticator data variable length raw binary string
|
||||
# string signed challenge variable length raw binary string
|
||||
# string ClientDataJSON variable length raw binary string
|
||||
packet = utils.lc_int(2)
|
||||
packet += utils.lc_int(number_of_assertions)
|
||||
|
||||
# Add authenticator data and signed challenge for each assertion
|
||||
for i in range(number_of_assertions):
|
||||
assertion_response = assertion.get_response(i)
|
||||
|
||||
# string<lenenc> authenticator_data
|
||||
authenticator_data = cbor_dump_bytes(assertion_response.authenticator_data)
|
||||
|
||||
# string<lenenc> signed_challenge
|
||||
signature = assertion_response.signature
|
||||
|
||||
packet += utils.lc_int(len(authenticator_data))
|
||||
packet += authenticator_data
|
||||
packet += utils.lc_int(len(signature))
|
||||
packet += signature
|
||||
|
||||
# string<lenenc> client_data_json
|
||||
client_data_json = assertion_response.client_data
|
||||
|
||||
packet += utils.lc_int(len(client_data_json))
|
||||
packet += client_data_json
|
||||
|
||||
logger.debug("WebAuthn - payload response packet: %s", packet)
|
||||
return packet
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Find authenticator device and check if supports resident keys.
|
||||
|
||||
It also creates a Fido2Client using the relying party ID from the server.
|
||||
|
||||
Raises:
|
||||
InterfaceError: When the FIDO device is not found.
|
||||
|
||||
Returns:
|
||||
bytes: 2 if the authenticator supports resident keys else 1.
|
||||
"""
|
||||
try:
|
||||
packets, capability = utils.read_int(auth_data, 1)
|
||||
challenge, rp_id = utils.read_lc_string_list(packets)
|
||||
self.options["challenge"] = challenge
|
||||
self.options["rpId"] = rp_id.decode()
|
||||
logger.debug("WebAuthn - capability: %d", capability)
|
||||
logger.debug("WebAuthn - challenge: %s", self.options["challenge"])
|
||||
logger.debug("WebAuthn - relying party id: %s", self.options["rpId"])
|
||||
except ValueError as err:
|
||||
raise errors.InterfaceError(
|
||||
"Unable to parse MySQL WebAuthn authentication data"
|
||||
) from err
|
||||
|
||||
# Locate a device
|
||||
device = next(CtapHidDevice.list_devices(), None)
|
||||
if device is not None:
|
||||
logger.debug("WebAuthn - Use USB HID channel")
|
||||
elif CTAP_PCSC_DEVICE_AVAILABLE:
|
||||
device = next(CtapPcscDevice.list_devices(), None)
|
||||
|
||||
if device is None:
|
||||
raise errors.InterfaceError("No FIDO device found")
|
||||
|
||||
# Set up a FIDO 2 client using the origin relying party id
|
||||
self.client = Fido2Client(
|
||||
device,
|
||||
f"https://{self.options['rpId']}",
|
||||
user_interaction=ClientInteraction(self.callback),
|
||||
)
|
||||
|
||||
if not self.client.info.options.get("rk"):
|
||||
logger.debug("WebAuthn - Authenticator doesn't support resident keys")
|
||||
return b"1"
|
||||
|
||||
logger.debug("WebAuthn - Authenticator with support for resident key found")
|
||||
return b"2"
|
||||
|
||||
async def auth_more_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth more data` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Authentication method data (from a packet representing
|
||||
an `auth more data` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
_, credential_id = utils.read_lc_string(auth_data)
|
||||
|
||||
response = self.get_assertion_response(credential_id)
|
||||
|
||||
logger.debug("WebAuthn - request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
pkt = bytes(await sock.read())
|
||||
logger.debug("WebAuthn - server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
webauth_callback = kwargs.get("webauthn_callback") or kwargs.get(
|
||||
"fido_callback"
|
||||
)
|
||||
self.callback = (
|
||||
utils.import_object(webauth_callback)
|
||||
if isinstance(webauth_callback, str)
|
||||
else webauth_callback
|
||||
)
|
||||
|
||||
response = self.auth_response(auth_data)
|
||||
credential_id = None
|
||||
|
||||
if response == b"1":
|
||||
# Authenticator doesn't support resident keys, request credential_id
|
||||
logger.debug("WebAuthn - request credential_id")
|
||||
await sock.write(utils.lc_int(int(response)))
|
||||
|
||||
# return a packet representing an `auth more data` response
|
||||
return bytes(await sock.read())
|
||||
|
||||
response = self.get_assertion_response(credential_id)
|
||||
|
||||
logger.debug("WebAuthn - request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
pkt = bytes(await sock.read())
|
||||
logger.debug("WebAuthn - server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,160 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Caching SHA2 Password Authentication Plugin."""
|
||||
|
||||
import struct
|
||||
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from mysql.connector.errors import InterfaceError
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLCachingSHA2PasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLCachingSHA2PasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL caching_sha2_password authentication plugin
|
||||
|
||||
Note that encrypting using RSA is not supported since the Python
|
||||
Standard Library does not provide this OpenSSL functionality.
|
||||
"""
|
||||
|
||||
perform_full_authentication: int = 4
|
||||
|
||||
def _scramble(self, auth_data: bytes) -> bytes:
|
||||
"""Return a scramble of the password using a Nonce sent by the
|
||||
server.
|
||||
|
||||
The scramble is of the form:
|
||||
XOR(SHA2(password), SHA2(SHA2(SHA2(password)), Nonce))
|
||||
"""
|
||||
if not auth_data:
|
||||
raise InterfaceError("Missing authentication data (seed)")
|
||||
|
||||
if not self._password:
|
||||
return b""
|
||||
|
||||
hash1 = sha256(self._password.encode()).digest()
|
||||
hash2 = sha256()
|
||||
hash2.update(sha256(hash1).digest())
|
||||
hash2.update(auth_data)
|
||||
hash2_digest = hash2.digest()
|
||||
xored = [h1 ^ h2 for (h1, h2) in zip(hash1, hash2_digest)]
|
||||
hash3 = struct.pack("32B", *xored)
|
||||
return hash3
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "caching_sha2_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Make the client's authorization response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
"""
|
||||
if not auth_data:
|
||||
return None
|
||||
if len(auth_data) > 1:
|
||||
return self._scramble(auth_data)
|
||||
if auth_data[0] == self.perform_full_authentication:
|
||||
# return password as clear text.
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
return None
|
||||
|
||||
async def auth_more_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth more data` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Authentication method data (from a packet representing
|
||||
an `auth more data` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response:
|
||||
await sock.write(response)
|
||||
|
||||
return bytes(await sock.read())
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
pkt = bytes(await sock.read())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,105 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Clear Password Authentication Plugin."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from mysql.connector import errors
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLClearPasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLClearPasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL Clear Password authentication plugin"""
|
||||
|
||||
def _prepare_password(self) -> bytes:
|
||||
"""Prepare and return password as as clear text.
|
||||
|
||||
Returns:
|
||||
bytes: Prepared password.
|
||||
"""
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "mysql_clear_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Return the prepared password to send to MySQL.
|
||||
|
||||
Raises:
|
||||
InterfaceError: When SSL is required by not enabled.
|
||||
|
||||
Returns:
|
||||
str: The prepared password.
|
||||
"""
|
||||
if self.requires_ssl and not self._ssl_enabled:
|
||||
raise errors.InterfaceError(f"{self.name} requires SSL")
|
||||
return self._prepare_password()
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
pkt = bytes(await sock.read())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,121 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Native Password Authentication Plugin."""
|
||||
|
||||
import struct
|
||||
|
||||
from hashlib import sha1
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from mysql.connector.errors import InterfaceError
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLNativePasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLNativePasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL Native Password authentication plugin"""
|
||||
|
||||
def _prepare_password(self, auth_data: bytes) -> bytes:
|
||||
"""Prepares and returns password as native MySQL 4.1+ password"""
|
||||
if not auth_data:
|
||||
raise InterfaceError("Missing authentication data (seed)")
|
||||
|
||||
if not self._password:
|
||||
return b""
|
||||
|
||||
hash4 = None
|
||||
try:
|
||||
hash1 = sha1(self._password.encode()).digest()
|
||||
hash2 = sha1(hash1).digest()
|
||||
hash3 = sha1(auth_data + hash2).digest()
|
||||
xored = [h1 ^ h3 for (h1, h3) in zip(hash1, hash3)]
|
||||
hash4 = struct.pack("20B", *xored)
|
||||
except (struct.error, TypeError) as err:
|
||||
raise InterfaceError(f"Failed scrambling password; {err}") from err
|
||||
|
||||
return hash4
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "mysql_native_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Make the client's authorization response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
"""
|
||||
return self._prepare_password(auth_data)
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
pkt = bytes(await sock.read())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,109 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""SHA256 Password Authentication Plugin."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from mysql.connector import errors
|
||||
from mysql.connector.logger import logger
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLSHA256PasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLSHA256PasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL SHA256 authentication plugin
|
||||
|
||||
Note that encrypting using RSA is not supported since the Python
|
||||
Standard Library does not provide this OpenSSL functionality.
|
||||
"""
|
||||
|
||||
def _prepare_password(self) -> bytes:
|
||||
"""Prepare and return password as as clear text.
|
||||
|
||||
Returns:
|
||||
password (bytes): Prepared password.
|
||||
"""
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "sha256_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return True
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Return the prepared password to send to MySQL.
|
||||
|
||||
Raises:
|
||||
InterfaceError: When SSL is required by not enabled.
|
||||
|
||||
Returns:
|
||||
str: The prepared password.
|
||||
"""
|
||||
if self.requires_ssl and not self.ssl_enabled:
|
||||
raise errors.InterfaceError(f"{self.name} requires SSL")
|
||||
return self._prepare_password()
|
||||
|
||||
async def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
await sock.write(response)
|
||||
|
||||
pkt = bytes(await sock.read())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,688 @@
|
||||
# Copyright (c) 2025, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Implementing pooling of connections to MySQL servers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, Any, Dict, NoReturn, Optional, Tuple, Type, Union
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from mysql.connector.constants import CNX_POOL_ARGS
|
||||
|
||||
try:
|
||||
import dns.asyncresolver
|
||||
import dns.exception
|
||||
except ImportError:
|
||||
HAVE_DNSPYTHON = False
|
||||
else:
|
||||
HAVE_DNSPYTHON = True
|
||||
|
||||
from ..errors import (
|
||||
Error,
|
||||
InterfaceError,
|
||||
NotSupportedError,
|
||||
PoolError,
|
||||
ProgrammingError,
|
||||
)
|
||||
from ..pooling import DEFAULT_CONFIGURATION, generate_pool_name, read_option_files
|
||||
from .connection import MySQLConnection
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
CONNECTION_POOL_LOCK = asyncio.Lock()
|
||||
CNX_POOL_MAXSIZE = 32
|
||||
CNX_POOL_MAXNAMESIZE = 64
|
||||
CNX_POOL_NAMEREGEX = re.compile(r"[^a-zA-Z0-9._:\-*$#]")
|
||||
MYSQL_CNX_CLASS: Union[type, Tuple[type, ...]] = (MySQLConnection,)
|
||||
|
||||
_CONNECTION_POOLS: Dict[str, MySQLConnectionPool] = {}
|
||||
|
||||
|
||||
async def _get_pooled_connection(**kwargs: Any) -> PooledMySQLConnection:
|
||||
"""Return a pooled MySQL connection."""
|
||||
# If no pool name specified, generate one
|
||||
pool_name = (
|
||||
kwargs["pool_name"] if "pool_name" in kwargs else generate_pool_name(**kwargs)
|
||||
)
|
||||
|
||||
# Setup the pool, ensuring only 1 thread can update at a time
|
||||
if pool_name not in _CONNECTION_POOLS:
|
||||
pool = MySQLConnectionPool(**kwargs)
|
||||
await pool.initialize_pool()
|
||||
|
||||
async with CONNECTION_POOL_LOCK:
|
||||
_CONNECTION_POOLS[pool_name] = pool
|
||||
elif isinstance(_CONNECTION_POOLS[pool_name], MySQLConnectionPool):
|
||||
# pool_size must be the same
|
||||
check_size = _CONNECTION_POOLS[pool_name].pool_size
|
||||
if "pool_size" in kwargs and kwargs["pool_size"] != check_size:
|
||||
raise PoolError("Size can not be changed for active pools.")
|
||||
|
||||
# Return pooled connection
|
||||
try:
|
||||
return await _CONNECTION_POOLS[pool_name].get_connection()
|
||||
except AttributeError:
|
||||
raise InterfaceError(
|
||||
f"Failed getting connection from pool '{pool_name}'"
|
||||
) from None
|
||||
|
||||
|
||||
async def _get_failover_connection(
|
||||
**kwargs: Any,
|
||||
) -> Union[PooledMySQLConnection, MySQLConnectionAbstract]:
|
||||
"""Return a MySQL connection and try to failover if needed.
|
||||
|
||||
An InterfaceError is raise when no MySQL is available. ValueError is
|
||||
raised when the failover server configuration contains an illegal
|
||||
connection argument. Supported arguments are user, password, host, port,
|
||||
unix_socket and database. ValueError is also raised when the failover
|
||||
argument was not provided.
|
||||
|
||||
Returns MySQLConnection instance.
|
||||
"""
|
||||
config = kwargs.copy()
|
||||
try:
|
||||
failover = config["failover"]
|
||||
except KeyError:
|
||||
raise ValueError("failover argument not provided") from None
|
||||
del config["failover"]
|
||||
|
||||
support_cnx_args = {
|
||||
"user",
|
||||
"password",
|
||||
"host",
|
||||
"port",
|
||||
"unix_socket",
|
||||
"database",
|
||||
"pool_name",
|
||||
"pool_size",
|
||||
"priority",
|
||||
}
|
||||
|
||||
# First check if we can add all use the configuration
|
||||
priority_count = 0
|
||||
for server in failover:
|
||||
diff = set(server.keys()) - support_cnx_args
|
||||
if diff:
|
||||
arg = "s" if len(diff) > 1 else ""
|
||||
lst = ", ".join(diff)
|
||||
raise ValueError(
|
||||
f"Unsupported connection argument {arg} in failover: {lst}"
|
||||
)
|
||||
if hasattr(server, "priority"):
|
||||
priority_count += 1
|
||||
|
||||
server["priority"] = server.get("priority", 100)
|
||||
if server["priority"] < 0 or server["priority"] > 100:
|
||||
raise InterfaceError(
|
||||
"Priority value should be in the range of 0 to 100, "
|
||||
f"got : {server['priority']}"
|
||||
)
|
||||
if not isinstance(server["priority"], int):
|
||||
raise InterfaceError(
|
||||
"Priority value should be an integer in the range of 0 to "
|
||||
f"100, got : {server['priority']}"
|
||||
)
|
||||
|
||||
if 0 < priority_count < len(failover):
|
||||
raise ProgrammingError(
|
||||
"You must either assign no priority to any "
|
||||
"of the routers or give a priority for "
|
||||
"every router"
|
||||
)
|
||||
|
||||
server_directory = {}
|
||||
server_priority_list = []
|
||||
for server in sorted(failover, key=lambda x: x["priority"], reverse=True):
|
||||
if server["priority"] not in server_directory:
|
||||
server_directory[server["priority"]] = [server]
|
||||
server_priority_list.append(server["priority"])
|
||||
else:
|
||||
server_directory[server["priority"]].append(server)
|
||||
|
||||
for priority in server_priority_list:
|
||||
failover_list = server_directory[priority]
|
||||
for _ in range(len(failover_list)):
|
||||
last = len(failover_list) - 1
|
||||
index = random.randint(0, last)
|
||||
server = failover_list.pop(index)
|
||||
new_config = config.copy()
|
||||
new_config.update(server)
|
||||
new_config.pop("priority", None)
|
||||
try:
|
||||
return await connect(**new_config)
|
||||
except Error:
|
||||
# If we failed to connect, we try the next server
|
||||
pass
|
||||
|
||||
raise InterfaceError("Unable to connect to any of the target hosts")
|
||||
|
||||
|
||||
async def connect(
|
||||
*args: Any, **kwargs: Any
|
||||
) -> Union[PooledMySQLConnection, MySQLConnectionAbstract]:
|
||||
"""Creates a MySQL connection object.
|
||||
|
||||
In its simpliest form, `connect()` will open a connection to a
|
||||
MySQL server and return a `MySQLConnectionAbstract` subclass
|
||||
object such as `MySQLConnection` or `CMySQLConnection`.
|
||||
|
||||
Args:
|
||||
*args: N/A.
|
||||
**kwargs: For a complete list of possible arguments, see [1]. If no arguments
|
||||
are given, it uses the already configured or default values.
|
||||
|
||||
Returns:
|
||||
A `MySQLConnectionAbstract` subclass instance (such as `MySQLConnection`)
|
||||
or a `PooledMySQLConnection` instance.
|
||||
|
||||
Raises:
|
||||
`mysql.connector.errors.NotSupportedError`: if pooled connection is not supported
|
||||
for the Python version and OS that are being used.
|
||||
|
||||
Examples:
|
||||
A connection with the MySQL server can be established using either the
|
||||
`mysql.connector.aio.connect()` method or a `MySQLConnectionAbstract` subclass:
|
||||
```
|
||||
>>> from mysql.connector.aio import MySQLConnection
|
||||
>>>
|
||||
>>> cnx1 = await mysql.connector.aio.connect(user='joe', database='test')
|
||||
>>> cnx2 = MySQLConnection(user='joe', database='test')
|
||||
>>>
|
||||
```
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
# DNS SRV
|
||||
dns_srv = kwargs.pop("dns_srv") if "dns_srv" in kwargs else False
|
||||
|
||||
if not isinstance(dns_srv, bool):
|
||||
raise InterfaceError("The value of 'dns-srv' must be a boolean")
|
||||
|
||||
if dns_srv:
|
||||
if not HAVE_DNSPYTHON:
|
||||
raise InterfaceError(
|
||||
"MySQL host configuration requested DNS "
|
||||
"SRV. This requires the Python dnspython "
|
||||
"module. Please refer to documentation"
|
||||
)
|
||||
if "unix_socket" in kwargs:
|
||||
raise InterfaceError(
|
||||
"Using Unix domain sockets with DNS SRV lookup is not allowed"
|
||||
)
|
||||
if "port" in kwargs:
|
||||
raise InterfaceError(
|
||||
"Specifying a port number with DNS SRV lookup is not allowed"
|
||||
)
|
||||
if "failover" in kwargs:
|
||||
raise InterfaceError(
|
||||
"Specifying multiple hostnames with DNS SRV look up is not allowed"
|
||||
)
|
||||
if "host" not in kwargs:
|
||||
kwargs["host"] = DEFAULT_CONFIGURATION["host"]
|
||||
|
||||
try:
|
||||
srv_records = await dns.asyncresolver.query(kwargs["host"], "SRV")
|
||||
except dns.exception.DNSException:
|
||||
raise InterfaceError(
|
||||
f"Unable to locate any hosts for '{kwargs['host']}'"
|
||||
) from None
|
||||
|
||||
failover = []
|
||||
for srv in srv_records:
|
||||
failover.append(
|
||||
{
|
||||
"host": srv.target.to_text(omit_final_dot=True),
|
||||
"port": srv.port,
|
||||
"priority": srv.priority,
|
||||
"weight": srv.weight,
|
||||
}
|
||||
)
|
||||
|
||||
failover.sort(key=lambda x: (x["priority"], -x["weight"]))
|
||||
kwargs["failover"] = [
|
||||
{"host": srv["host"], "port": srv["port"]} for srv in failover
|
||||
]
|
||||
|
||||
# Failover
|
||||
if "failover" in kwargs:
|
||||
return await _get_failover_connection(**kwargs)
|
||||
|
||||
# Pooled connections
|
||||
try:
|
||||
if any(key in kwargs for key in CNX_POOL_ARGS):
|
||||
return await _get_pooled_connection(**kwargs)
|
||||
except NameError:
|
||||
# No pooling
|
||||
pass
|
||||
|
||||
# Option files
|
||||
if "read_default_file" in kwargs:
|
||||
kwargs["option_files"] = kwargs["read_default_file"]
|
||||
kwargs.pop("read_default_file")
|
||||
|
||||
if "option_files" in kwargs:
|
||||
new_config = read_option_files(**kwargs)
|
||||
return await connect(**new_config)
|
||||
|
||||
if "use_pure" in kwargs:
|
||||
del kwargs["use_pure"] # Remove 'use_pure' from kwargs
|
||||
|
||||
cnx = MySQLConnection(*args, **kwargs)
|
||||
await cnx.connect()
|
||||
return cnx
|
||||
|
||||
|
||||
def _check_support() -> None:
|
||||
"""Raises error if the Python version and OS combination is not
|
||||
supported for Pooling."""
|
||||
py_version_info = sys.version_info
|
||||
if os.name == "nt" and py_version_info.major == 3 and py_version_info.minor <= 10:
|
||||
raise NotSupportedError(
|
||||
"Pooled connections are not supported for Python versions "
|
||||
"lower than 3.11 on Windows"
|
||||
)
|
||||
|
||||
|
||||
class PooledMySQLConnection:
|
||||
"""Class holding a MySQL Connection in a pool
|
||||
PooledMySQLConnection is used by MySQLConnectionPool to return an
|
||||
instance holding a MySQL connection. It works like a MySQLConnection
|
||||
except for methods like close() and config().
|
||||
The close()-method will add the connection back to the pool rather
|
||||
than disconnecting from the MySQL server.
|
||||
Configuring the connection have to be done through the MySQLConnectionPool
|
||||
method set_config(). Using config() on pooled connection will raise a
|
||||
PoolError.
|
||||
Attributes:
|
||||
pool_name (str): Returns the name of the connection pool to which the
|
||||
connection belongs.
|
||||
"""
|
||||
|
||||
def __init__(self, pool: MySQLConnectionPool, cnx: MySQLConnectionAbstract) -> None:
|
||||
"""Constructor.
|
||||
Args:
|
||||
pool: A `MySQLConnectionPool` instance.
|
||||
cnx: A `MySQLConnectionAbstract` subclass instance.
|
||||
"""
|
||||
_check_support()
|
||||
|
||||
if not isinstance(pool, MySQLConnectionPool):
|
||||
raise AttributeError("pool must be an instance of MySQLConnectionPool")
|
||||
if not isinstance(cnx, MYSQL_CNX_CLASS):
|
||||
raise AttributeError("cnx should be an instance of MySQLConnection")
|
||||
self._cnx_pool: MySQLConnectionPool = pool
|
||||
self._cnx: MySQLConnectionAbstract = cnx
|
||||
|
||||
async def __aenter__(self) -> PooledMySQLConnection:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: Type[BaseException],
|
||||
exc_value: BaseException,
|
||||
traceback: TracebackType,
|
||||
) -> None:
|
||||
await self.close()
|
||||
|
||||
def __getattr__(self, attr: Any) -> Any:
|
||||
"""Calls attributes of the MySQLConnection instance"""
|
||||
return getattr(self._cnx, attr)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Do not close, but adds connection back to pool.
|
||||
For a pooled connection, close() does not actually close it but returns it
|
||||
to the pool and makes it available for subsequent connection requests. If the
|
||||
pool configuration parameters are changed, a returned connection is closed
|
||||
and reopened with the new configuration before being returned from the pool
|
||||
again in response to a connection request.
|
||||
"""
|
||||
cnx = self._cnx
|
||||
try:
|
||||
if self._cnx_pool.can_reset_session and await cnx.is_connected():
|
||||
await cnx.reset_session()
|
||||
finally:
|
||||
await self._cnx_pool.add_connection(cnx)
|
||||
self._cnx = None
|
||||
|
||||
@staticmethod
|
||||
def config(**kwargs: Any) -> NoReturn:
|
||||
"""Configuration is done through the pool.
|
||||
For pooled connections, the `config()` method raises a `PoolError`
|
||||
exception. Configuration for pooled connections should be done
|
||||
using the pool object.
|
||||
"""
|
||||
raise PoolError(
|
||||
"Configuration for pooled connections should be done through the "
|
||||
"pool itself"
|
||||
)
|
||||
|
||||
@property
|
||||
def pool_name(self) -> str:
|
||||
"""Returns the name of the connection pool to which the connection belongs."""
|
||||
return self._cnx_pool.pool_name
|
||||
|
||||
|
||||
class MySQLConnectionPool:
|
||||
"""Class defining a pool of MySQL connections"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pool_size: int = 5,
|
||||
pool_name: Optional[str] = None,
|
||||
pool_reset_session: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Constructor.
|
||||
|
||||
Initialize a MySQL connection pool with a maximum number of
|
||||
connections set to `pool_size`.
|
||||
|
||||
NOTE: The coroutine `await cnxpool.initialize_pool(**dbconfig)` must be called
|
||||
after initializing this class to open up the pool of required number of database
|
||||
connections and making the instance of MySQLConnectionPool ready-to-use.
|
||||
Contents of `dbconfig` is described in [1].
|
||||
|
||||
Args:
|
||||
pool_name: The pool name. If this argument is not given, Connector/Python
|
||||
automatically generates the name, composed from whichever of
|
||||
the host, port, user, and database connection arguments are
|
||||
given in kwargs, in that order.
|
||||
pool_size: The pool size. If this argument is not given, the default is 5.
|
||||
pool_reset_session: Whether to reset session variables when the connection
|
||||
is returned to the pool.
|
||||
|
||||
Examples:
|
||||
```
|
||||
>>> dbconfig = {
|
||||
>>> "database": "test",
|
||||
>>> "user": "joe",
|
||||
>>> }
|
||||
>>> # initialize the MySQLConnectionPool instance
|
||||
>>> cnxpool = mysql.connector.aio.pooling.MySQLConnectionPool(
|
||||
>>> pool_name = "mypool",
|
||||
>>> pool_size = 3,
|
||||
>>> )
|
||||
>>> # Open up the pool of connections
|
||||
>>> await cnxpool.initialize_pool(**dbconfig)
|
||||
>>> # cnxpool is ready to be used ...
|
||||
```
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
_check_support()
|
||||
|
||||
self._pool_size: Optional[int] = None
|
||||
self._pool_name: Optional[str] = None
|
||||
self._reset_session: bool = pool_reset_session
|
||||
self._set_pool_size(pool_size)
|
||||
if pool_name:
|
||||
self._set_pool_name(pool_name)
|
||||
self._cnx_config: Dict[str, Any] = kwargs
|
||||
self._cnx_queue: asyncio.Queue[MySQLConnectionAbstract] = asyncio.Queue(
|
||||
self._pool_size
|
||||
)
|
||||
self._config_version: UUID = uuid4()
|
||||
|
||||
async def initialize_pool(self) -> None:
|
||||
"""Opens the connection pool and fill with MySQL database connections.
|
||||
|
||||
This coroutine must be called after initializing an instance of MySQLConnectionPool
|
||||
to make the build the pool of connections and make the instance ready to be used.
|
||||
|
||||
Args:
|
||||
**kwargs: Connection arguments, as described in [1].
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
if not self._pool_name:
|
||||
async with CONNECTION_POOL_LOCK:
|
||||
self._set_pool_name(generate_pool_name(**self._cnx_config))
|
||||
if self._cnx_config:
|
||||
await self.set_config(**self._cnx_config)
|
||||
cnt = 0
|
||||
while cnt < self._pool_size:
|
||||
await self.add_connection()
|
||||
cnt += 1
|
||||
|
||||
@property
|
||||
def pool_name(self) -> str:
|
||||
"""Returns the name of the connection pool."""
|
||||
return self._pool_name
|
||||
|
||||
@property
|
||||
def pool_size(self) -> int:
|
||||
"""Returns number of connections managed by the pool."""
|
||||
return self._pool_size
|
||||
|
||||
@property
|
||||
def can_reset_session(self) -> bool:
|
||||
"""Returns whether to reset session."""
|
||||
return self._reset_session
|
||||
|
||||
async def set_config(self, **kwargs: Any) -> None:
|
||||
"""Set the connection configuration for `MySQLConnectionAbstract` subclass instances.
|
||||
This method sets the configuration used for creating `MySQLConnectionAbstract`
|
||||
subclass instances such as `MySQLConnection`. See [1] for valid
|
||||
connection arguments.
|
||||
Args:
|
||||
**kwargs: Connection arguments - for a complete list of possible
|
||||
arguments, see [1].
|
||||
Raises:
|
||||
PoolError: When a connection argument is not valid, missing
|
||||
or not supported by `MySQLConnectionAbstract`.
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
if not kwargs:
|
||||
return
|
||||
|
||||
try:
|
||||
# test if we're able to connect using the provided connection options
|
||||
cnx = await connect(**kwargs)
|
||||
await cnx.disconnect()
|
||||
async with CONNECTION_POOL_LOCK:
|
||||
self._cnx_config = kwargs
|
||||
self._config_version = uuid4()
|
||||
except Exception as err:
|
||||
raise PoolError(f"Connection configuration not valid: {err}") from err
|
||||
|
||||
def _set_pool_size(self, pool_size: int) -> None:
|
||||
"""Set the size of the pool
|
||||
This method sets the size of the pool but it will not resize the pool.
|
||||
Raises an AttributeError when the pool_size is not valid. Invalid size
|
||||
is 0, negative or higher than pooling.CNX_POOL_MAXSIZE.
|
||||
"""
|
||||
if pool_size <= 0 or pool_size > CNX_POOL_MAXSIZE:
|
||||
raise AttributeError(
|
||||
"Pool size should be higher than 0 and lower or equal to "
|
||||
f"{CNX_POOL_MAXSIZE}"
|
||||
)
|
||||
self._pool_size = pool_size
|
||||
|
||||
def _set_pool_name(self, pool_name: str) -> None:
|
||||
r"""Set the name of the pool.
|
||||
This method checks the validity and sets the name of the pool.
|
||||
Raises an AttributeError when pool_name contains illegal characters
|
||||
([^a-zA-Z0-9._\-*$#]) or is longer than pooling.CNX_POOL_MAXNAMESIZE.
|
||||
"""
|
||||
if CNX_POOL_NAMEREGEX.search(pool_name):
|
||||
raise AttributeError(f"Pool name '{pool_name}' contains illegal characters")
|
||||
if len(pool_name) > CNX_POOL_MAXNAMESIZE:
|
||||
raise AttributeError(f"Pool name '{pool_name}' is too long")
|
||||
self._pool_name = pool_name
|
||||
|
||||
def _queue_connection(self, cnx: MySQLConnectionAbstract) -> None:
|
||||
"""Put connection back in the queue
|
||||
This method is putting a connection back in the queue. It will not
|
||||
acquire a lock as the methods using _queue_connection() will have it
|
||||
set.
|
||||
Raises `PoolError` on errors.
|
||||
"""
|
||||
if not isinstance(cnx, MYSQL_CNX_CLASS):
|
||||
raise PoolError(
|
||||
"Connection instance not subclass of MySQLConnectionAbstract"
|
||||
)
|
||||
|
||||
try:
|
||||
self._cnx_queue.put_nowait(cnx)
|
||||
except asyncio.QueueFull as err:
|
||||
raise PoolError("Failed adding connection; queue is full") from err
|
||||
|
||||
async def add_connection(
|
||||
self, cnx: Optional[MySQLConnectionAbstract] = None
|
||||
) -> None:
|
||||
"""Adds a connection to the pool.
|
||||
This method instantiates a `MySQLConnection` using the configuration
|
||||
passed when initializing the `MySQLConnectionPool` instance or using
|
||||
the `set_config()` method.
|
||||
If cnx is a `MySQLConnection` instance, it will be added to the
|
||||
queue.
|
||||
Args:
|
||||
cnx: The `MySQLConnectionAbstract` subclass object to be added to
|
||||
the pool. If this argument is missing (aka `None`), the pool
|
||||
creates a new connection and adds it.
|
||||
Raises:
|
||||
PoolError: When no configuration is set, when no more
|
||||
connection can be added (maximum reached) or when the connection
|
||||
can not be instantiated.
|
||||
"""
|
||||
if not self._cnx_config:
|
||||
raise PoolError("Connection configuration not available")
|
||||
|
||||
if self._cnx_queue.full():
|
||||
raise PoolError("Failed adding connection; queue is full")
|
||||
|
||||
if not cnx:
|
||||
cnx = await connect(**self._cnx_config)
|
||||
try:
|
||||
if (
|
||||
self._reset_session
|
||||
and self._cnx_config["compress"]
|
||||
and cnx.get_server_version() < (5, 7, 3)
|
||||
):
|
||||
raise NotSupportedError(
|
||||
"Pool reset session is not supported with "
|
||||
"compression for MySQL server version 5.7.2 "
|
||||
"or earlier"
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
cnx.pool_config_version = self._config_version
|
||||
else:
|
||||
if not isinstance(cnx, MYSQL_CNX_CLASS):
|
||||
raise PoolError(
|
||||
"Connection instance not subclass of MySQLConnectionAbstract"
|
||||
)
|
||||
|
||||
self._queue_connection(cnx)
|
||||
|
||||
async def get_connection(self) -> PooledMySQLConnection:
|
||||
"""Gets a connection from the pool.
|
||||
This method returns an PooledMySQLConnection instance which
|
||||
has a reference to the pool that created it, and the next available
|
||||
MySQL connection.
|
||||
When the MySQL connection is not connect, a reconnect is attempted.
|
||||
Returns:
|
||||
A `PooledMySQLConnection` instance.
|
||||
Raises:
|
||||
PoolError: On errors.
|
||||
"""
|
||||
|
||||
try:
|
||||
cnx = self._cnx_queue.get_nowait()
|
||||
except asyncio.QueueEmpty as err:
|
||||
raise PoolError("Failed getting connection; pool exhausted") from err
|
||||
|
||||
if (
|
||||
not await cnx.is_connected()
|
||||
or self._config_version != cnx.pool_config_version
|
||||
):
|
||||
try:
|
||||
cnx._set_connection_options(**self._cnx_config)
|
||||
await cnx.reconnect()
|
||||
except InterfaceError:
|
||||
self._queue_connection(cnx)
|
||||
raise
|
||||
cnx.pool_config_version = self._config_version
|
||||
|
||||
return PooledMySQLConnection(self, cnx)
|
||||
|
||||
async def _remove_connections(self) -> int:
|
||||
"""Close all connections
|
||||
This method closes all connections and returns the number of connections it closed.
|
||||
This method should be used internally while cleaning-up the connection pool by closing each
|
||||
and every active connection objects currently residing in the pool.
|
||||
|
||||
Returns:
|
||||
An integer defining the number of open connections closed during clean-up.
|
||||
Raises:
|
||||
PoolError: On errors while fetching connections from the pool or while disconnecting
|
||||
an open connection.
|
||||
"""
|
||||
cnt = 0
|
||||
cnxq = self._cnx_queue
|
||||
while cnxq.qsize():
|
||||
try:
|
||||
cnx = cnxq.get_nowait()
|
||||
await cnx.disconnect()
|
||||
cnt += 1
|
||||
except asyncio.QueueEmpty:
|
||||
return cnt
|
||||
except PoolError:
|
||||
raise
|
||||
except Error:
|
||||
# Any other error when closing means connection is closed
|
||||
pass
|
||||
|
||||
return cnt
|
||||
|
||||
async def close_pool(self) -> int:
|
||||
"""Cleans up the connection pool
|
||||
This public method gracefully shuts down the connection pool by disconnecting all of the
|
||||
open connections currently active in the pool.
|
||||
|
||||
Returns:
|
||||
An integer defining the number of open connections closed while shutting down the pool.
|
||||
Raises:
|
||||
PoolError: On errors while fetching connections from the pool or while disconnecting
|
||||
an open connection.
|
||||
"""
|
||||
return await self._remove_connections()
|
||||
@@ -0,0 +1,325 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Implements the MySQL Client/Server protocol."""
|
||||
|
||||
__all__ = ["MySQLProtocol"]
|
||||
|
||||
import struct
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..constants import ClientFlag, ServerCmd
|
||||
from ..errors import InterfaceError, ProgrammingError, get_exception
|
||||
from ..logger import logger
|
||||
from ..protocol import (
|
||||
DEFAULT_CHARSET_ID,
|
||||
DEFAULT_MAX_ALLOWED_PACKET,
|
||||
MySQLProtocol as _MySQLProtocol,
|
||||
)
|
||||
from ..types import BinaryProtocolType, DescriptionType, EofPacketType, HandShakeType
|
||||
from ..utils import lc_int, read_lc_string_list
|
||||
from .network import MySQLSocket
|
||||
from .plugins import MySQLAuthPlugin, get_auth_plugin
|
||||
from .plugins.caching_sha2_password import MySQLCachingSHA2PasswordAuthPlugin
|
||||
|
||||
|
||||
class MySQLProtocol(_MySQLProtocol):
|
||||
"""Implements MySQL client/server protocol.
|
||||
|
||||
Create and parses MySQL packets.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def auth_plugin_first_response( # type: ignore[override]
|
||||
auth_data: bytes,
|
||||
username: str,
|
||||
password: str,
|
||||
auth_plugin: str,
|
||||
auth_plugin_class: Optional[str] = None,
|
||||
ssl_enabled: bool = False,
|
||||
plugin_config: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[bytes, MySQLAuthPlugin]:
|
||||
"""Prepare the first authentication response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data from initial handshake.
|
||||
username: Account's username.
|
||||
password: Account's password.
|
||||
client_flags: Integer representing client capabilities flags.
|
||||
auth_plugin: Authorization plugin name.
|
||||
auth_plugin_class: Authorization plugin class (has higher precedence
|
||||
than the authorization plugin name).
|
||||
ssl_enabled: Whether SSL is enabled or not.
|
||||
plugin_config: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override
|
||||
the ones defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
auth_response: Authorization plugin response.
|
||||
auth_strategy: Authorization plugin instance created based
|
||||
on the provided `auth_plugin` and `auth_plugin_class`
|
||||
parameters.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If authentication fails or when got a NULL auth response.
|
||||
"""
|
||||
if not password and auth_plugin == "":
|
||||
# return auth response and an arbitrary auth strategy
|
||||
return b"\x00", MySQLCachingSHA2PasswordAuthPlugin(
|
||||
username, password, ssl_enabled=ssl_enabled
|
||||
)
|
||||
|
||||
if plugin_config is None:
|
||||
plugin_config = {}
|
||||
|
||||
try:
|
||||
auth_strategy = get_auth_plugin(auth_plugin, auth_plugin_class)(
|
||||
username, password, ssl_enabled=ssl_enabled
|
||||
)
|
||||
auth_response = auth_strategy.auth_response(auth_data, **plugin_config)
|
||||
except (TypeError, InterfaceError) as err:
|
||||
raise InterfaceError(f"Failed authentication: {err}") from err
|
||||
|
||||
if auth_response is None:
|
||||
raise InterfaceError(
|
||||
"Got NULL auth response while authenticating with "
|
||||
f"plugin {auth_strategy.name}"
|
||||
)
|
||||
|
||||
auth_response = lc_int(len(auth_response)) + auth_response
|
||||
|
||||
return auth_response, auth_strategy
|
||||
|
||||
@staticmethod
|
||||
def make_auth( # type: ignore[override]
|
||||
handshake: HandShakeType,
|
||||
username: str,
|
||||
password: str,
|
||||
database: Optional[str] = None,
|
||||
charset: int = DEFAULT_CHARSET_ID,
|
||||
client_flags: int = 0,
|
||||
max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET,
|
||||
auth_plugin: Optional[str] = None,
|
||||
auth_plugin_class: Optional[str] = None,
|
||||
conn_attrs: Optional[Dict[str, str]] = None,
|
||||
is_change_user_request: bool = False,
|
||||
ssl_enabled: bool = False,
|
||||
plugin_config: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[bytes, MySQLAuthPlugin]:
|
||||
"""Make a MySQL Authentication packet.
|
||||
|
||||
Args:
|
||||
handshake: Initial handshake.
|
||||
username: Account's username.
|
||||
password: Account's password.
|
||||
database: Initial database name for the connection
|
||||
charset: Client charset (see [2]), only the lower 8-bits.
|
||||
client_flags: Integer representing client capabilities flags.
|
||||
max_allowed_packet: Maximum packet size.
|
||||
auth_plugin: Authorization plugin name.
|
||||
auth_plugin_class: Authorization plugin class (has higher precedence
|
||||
than the authorization plugin name).
|
||||
conn_attrs: Connection attributes.
|
||||
is_change_user_request: Whether is a `change user request` operation or not.
|
||||
ssl_enabled: Whether SSL is enabled or not.
|
||||
plugin_config: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override
|
||||
the one defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
handshake_response: Handshake response as per [1].
|
||||
auth_strategy: Authorization plugin instance created based
|
||||
on the provided `auth_plugin` and `auth_plugin_class`.
|
||||
|
||||
Raises:
|
||||
ProgrammingError: Handshake misses authentication info.
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\
|
||||
page_protocol_connection_phase_packets_protocol_handshake_response.html
|
||||
|
||||
[2]: https://dev.mysql.com/doc/dev/mysql-server/latest/\
|
||||
page_protocol_basic_character_set.html#a_protocol_character_set
|
||||
"""
|
||||
b_username = username.encode()
|
||||
response_payload = []
|
||||
|
||||
if is_change_user_request:
|
||||
logger.debug("Got a `change user` request")
|
||||
|
||||
logger.debug("Starting authorization phase")
|
||||
if handshake is None:
|
||||
raise ProgrammingError("Got a NULL handshake") from None
|
||||
|
||||
if handshake.get("auth_data") is None:
|
||||
raise ProgrammingError("Handshake misses authentication info") from None
|
||||
|
||||
try:
|
||||
auth_plugin = auth_plugin or handshake["auth_plugin"] # type: ignore[assignment]
|
||||
except (TypeError, KeyError) as err:
|
||||
raise ProgrammingError(
|
||||
f"Handshake misses authentication plugin info ({err})"
|
||||
) from None
|
||||
|
||||
logger.debug("The provided initial strategy is %s", auth_plugin)
|
||||
|
||||
if is_change_user_request:
|
||||
response_payload.append(
|
||||
struct.pack(
|
||||
f"<B{len(b_username)}sx",
|
||||
ServerCmd.CHANGE_USER,
|
||||
b_username,
|
||||
)
|
||||
)
|
||||
else:
|
||||
filler = "x" * 23
|
||||
response_payload.append(
|
||||
struct.pack(
|
||||
f"<IIB{filler}{len(b_username)}sx",
|
||||
client_flags,
|
||||
max_allowed_packet,
|
||||
charset,
|
||||
b_username,
|
||||
)
|
||||
)
|
||||
|
||||
# auth plugin response
|
||||
auth_response, auth_strategy = MySQLProtocol.auth_plugin_first_response(
|
||||
auth_data=handshake["auth_data"], # type: ignore[arg-type]
|
||||
username=username,
|
||||
password=password,
|
||||
auth_plugin=auth_plugin,
|
||||
auth_plugin_class=auth_plugin_class,
|
||||
ssl_enabled=ssl_enabled,
|
||||
plugin_config=plugin_config,
|
||||
)
|
||||
response_payload.append(auth_response)
|
||||
|
||||
# database name
|
||||
response_payload.append(MySQLProtocol.connect_with_db(client_flags, database))
|
||||
|
||||
# charset
|
||||
if is_change_user_request:
|
||||
response_payload.append(struct.pack("<H", charset))
|
||||
|
||||
# plugin name
|
||||
if client_flags & ClientFlag.PLUGIN_AUTH:
|
||||
response_payload.append(auth_plugin.encode() + b"\x00")
|
||||
|
||||
# connection attributes
|
||||
if (client_flags & ClientFlag.CONNECT_ARGS) and conn_attrs is not None:
|
||||
response_payload.append(MySQLProtocol.make_conn_attrs(conn_attrs))
|
||||
|
||||
return b"".join(response_payload), auth_strategy
|
||||
|
||||
# pylint: disable=invalid-overridden-method
|
||||
async def read_binary_result( # type: ignore[override]
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
columns: List[DescriptionType],
|
||||
count: int = 1,
|
||||
charset: str = "utf-8",
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> Tuple[
|
||||
List[Tuple[BinaryProtocolType, ...]],
|
||||
Optional[EofPacketType],
|
||||
]:
|
||||
"""Read MySQL binary protocol result.
|
||||
|
||||
Reads all or given number of binary resultset rows from the socket.
|
||||
"""
|
||||
rows = []
|
||||
eof = None
|
||||
values = None
|
||||
i = 0
|
||||
while True:
|
||||
if eof or i == count:
|
||||
break
|
||||
packet = await sock.read(read_timeout)
|
||||
if packet[4] == 254:
|
||||
eof = self.parse_eof(packet)
|
||||
values = None
|
||||
elif packet[4] == 0:
|
||||
eof = None
|
||||
values = self._parse_binary_values(columns, packet[5:], charset)
|
||||
if eof is None and values is not None:
|
||||
rows.append(values)
|
||||
elif eof is None and values is None:
|
||||
raise get_exception(packet)
|
||||
i += 1
|
||||
return (rows, eof)
|
||||
|
||||
# pylint: disable=invalid-overridden-method
|
||||
async def read_text_result( # type: ignore[override]
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
version: Tuple[int, ...],
|
||||
count: int = 1,
|
||||
read_timeout: Optional[int] = None,
|
||||
) -> Tuple[
|
||||
List[Tuple[Optional[bytes], ...]],
|
||||
Optional[EofPacketType],
|
||||
]:
|
||||
"""Read MySQL text result.
|
||||
|
||||
Reads all or given number of rows from the socket.
|
||||
|
||||
Returns a tuple with 2 elements: a list with all rows and
|
||||
the EOF packet.
|
||||
"""
|
||||
# Keep unused 'version' for API backward compatibility
|
||||
_ = version
|
||||
rows = []
|
||||
eof = None
|
||||
rowdata = None
|
||||
i = 0
|
||||
while True:
|
||||
if eof or i == count:
|
||||
break
|
||||
packet = await sock.read(read_timeout)
|
||||
if packet.startswith(b"\xff\xff\xff"):
|
||||
datas = [packet[4:]]
|
||||
packet = await sock.read(read_timeout)
|
||||
while packet.startswith(b"\xff\xff\xff"):
|
||||
datas.append(packet[4:])
|
||||
packet = await sock.read(read_timeout)
|
||||
datas.append(packet[4:])
|
||||
rowdata = read_lc_string_list(b"".join(datas))
|
||||
elif packet[4] == 254 and packet[0] < 7:
|
||||
eof = self.parse_eof(packet)
|
||||
rowdata = None
|
||||
else:
|
||||
eof = None
|
||||
rowdata = read_lc_string_list(bytes(packet[4:]))
|
||||
if eof is None and rowdata is not None:
|
||||
rows.append(rowdata)
|
||||
elif eof is None and rowdata is None:
|
||||
raise get_exception(packet)
|
||||
i += 1
|
||||
return rows, eof
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
@@ -0,0 +1,155 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="attr-defined"
|
||||
# pylint: disable=protected-access
|
||||
|
||||
"""Utilities."""
|
||||
|
||||
__all__ = ["to_thread", "open_connection"]
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import functools
|
||||
|
||||
try:
|
||||
import ssl
|
||||
except ImportError:
|
||||
ssl = None
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mysql.connector.aio.abstracts import MySQLConnectionAbstract
|
||||
|
||||
__all__.append("StreamWriter")
|
||||
|
||||
|
||||
class StreamReaderProtocol(asyncio.StreamReaderProtocol):
|
||||
"""Extends asyncio.streams.StreamReaderProtocol for adding start_tls().
|
||||
|
||||
The ``start_tls()`` is based on ``asyncio.streams.StreamWriter`` introduced
|
||||
in Python 3.11. It provides the same functionality for older Python versions.
|
||||
"""
|
||||
|
||||
def _replace_writer(self, writer: asyncio.StreamWriter) -> None:
|
||||
"""Replace stream writer.
|
||||
|
||||
Args:
|
||||
writer: Stream Writer.
|
||||
"""
|
||||
transport = writer.transport
|
||||
self._stream_writer = writer
|
||||
self._transport = transport
|
||||
self._over_ssl = transport.get_extra_info("sslcontext") is not None
|
||||
|
||||
|
||||
class StreamWriter(asyncio.streams.StreamWriter):
|
||||
"""Extends asyncio.streams.StreamWriter for adding start_tls().
|
||||
|
||||
The ``start_tls()`` is based on ``asyncio.streams.StreamWriter`` introduced
|
||||
in Python 3.11. It provides the same functionality for older Python versions.
|
||||
"""
|
||||
|
||||
async def start_tls(
|
||||
self,
|
||||
ssl_context: ssl.SSLContext,
|
||||
*,
|
||||
server_hostname: str = None,
|
||||
ssl_handshake_timeout: int = None,
|
||||
) -> None:
|
||||
"""Upgrade an existing stream-based connection to TLS.
|
||||
|
||||
Args:
|
||||
ssl_context: Configured SSL context.
|
||||
server_hostname: Server host name.
|
||||
ssl_handshake_timeout: SSL handshake timeout.
|
||||
"""
|
||||
server_side = self._protocol._client_connected_cb is not None
|
||||
protocol = self._protocol
|
||||
await self.drain()
|
||||
new_transport = await self._loop.start_tls(
|
||||
# pylint: disable=access-member-before-definition
|
||||
self._transport, # type: ignore[has-type]
|
||||
protocol,
|
||||
ssl_context,
|
||||
server_side=server_side,
|
||||
server_hostname=server_hostname,
|
||||
ssl_handshake_timeout=ssl_handshake_timeout,
|
||||
)
|
||||
self._transport = ( # pylint: disable=attribute-defined-outside-init
|
||||
new_transport
|
||||
)
|
||||
protocol._replace_writer(self)
|
||||
|
||||
|
||||
async def open_connection(
|
||||
host: str = None, port: int = None, *, limit: int = 2**16, **kwds: Any
|
||||
) -> Tuple[asyncio.StreamReader, StreamWriter]:
|
||||
"""A wrapper for create_connection() returning a (reader, writer) pair.
|
||||
|
||||
This function is based on ``asyncio.streams.open_connection`` and adds a custom
|
||||
stream reader.
|
||||
|
||||
MySQL expects TLS negotiation to happen in the middle of a TCP connection, not at
|
||||
the start.
|
||||
This function in conjunction with ``_StreamReaderProtocol`` and ``_StreamWriter``
|
||||
allows the TLS negotiation on an existing connection.
|
||||
|
||||
Args:
|
||||
host: Server host name.
|
||||
port: Server port.
|
||||
limit: The buffer size limit used by the returned ``StreamReader`` instance.
|
||||
By default the limit is set to 64 KiB.
|
||||
|
||||
Returns:
|
||||
tuple: Returns a pair of reader and writer objects that are instances of
|
||||
``StreamReader`` and ``StreamWriter`` classes.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
reader = asyncio.streams.StreamReader(limit=limit, loop=loop)
|
||||
protocol = StreamReaderProtocol(reader, loop=loop)
|
||||
transport, _ = await loop.create_connection(lambda: protocol, host, port, **kwds)
|
||||
writer = StreamWriter(transport, protocol, reader, loop)
|
||||
return reader, writer
|
||||
|
||||
|
||||
async def to_thread(func: Callable, *args: Any, **kwargs: Any) -> asyncio.Future:
|
||||
"""Asynchronously run function ``func`` in a separate thread.
|
||||
|
||||
This function is based on ``asyncio.to_thread()`` introduced in Python 3.9, which
|
||||
provides the same functionality for older Python versions.
|
||||
|
||||
Returns:
|
||||
coroutine: A coroutine that can be awaited to get the eventual result of
|
||||
``func``.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
ctx = contextvars.copy_context()
|
||||
func_call = functools.partial(ctx.run, func, *args, **kwargs)
|
||||
return await loop.run_in_executor(None, func_call)
|
||||
@@ -0,0 +1,385 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Implementing support for MySQL Authentication Plugins"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from .errors import InterfaceError, NotSupportedError, get_exception
|
||||
from .logger import logger
|
||||
from .plugins import MySQLAuthPlugin, get_auth_plugin
|
||||
from .protocol import (
|
||||
AUTH_SWITCH_STATUS,
|
||||
DEFAULT_CHARSET_ID,
|
||||
DEFAULT_MAX_ALLOWED_PACKET,
|
||||
ERR_STATUS,
|
||||
EXCHANGE_FURTHER_STATUS,
|
||||
MFA_STATUS,
|
||||
OK_STATUS,
|
||||
MySQLProtocol,
|
||||
)
|
||||
from .types import HandShakeType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .network import MySQLSocket
|
||||
|
||||
|
||||
class MySQLAuthenticator:
|
||||
"""Implements the authentication phase."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Constructor."""
|
||||
self._username: str = ""
|
||||
self._passwords: Dict[int, str] = {}
|
||||
self._plugin_config: Dict[str, Any] = {}
|
||||
self._ssl_enabled: bool = False
|
||||
self._auth_strategy: Optional[MySQLAuthPlugin] = None
|
||||
self._auth_plugin_class: Optional[str] = None
|
||||
|
||||
@property
|
||||
def ssl_enabled(self) -> bool:
|
||||
"""Signals whether or not SSL is enabled."""
|
||||
return self._ssl_enabled
|
||||
|
||||
@property
|
||||
def plugin_config(self) -> Dict[str, Any]:
|
||||
"""Custom arguments that are being provided to the authentication plugin when called.
|
||||
|
||||
The parameters defined here will override the ones defined in the
|
||||
auth plugin itself.
|
||||
|
||||
The plugin config is a read-only property - the plugin configuration
|
||||
provided when invoking `authenticate()` is recorded and can be queried
|
||||
by accessing this property.
|
||||
|
||||
Returns:
|
||||
dict: The latest plugin configuration provided when invoking
|
||||
`authenticate()`.
|
||||
"""
|
||||
return self._plugin_config
|
||||
|
||||
def update_plugin_config(self, config: Dict[str, Any]) -> None:
|
||||
"""Update the 'plugin_config' instance variable"""
|
||||
self._plugin_config.update(config)
|
||||
|
||||
def setup_ssl(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
host: str,
|
||||
ssl_options: Optional[Dict[str, Any]],
|
||||
charset: int = DEFAULT_CHARSET_ID,
|
||||
client_flags: int = 0,
|
||||
max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET,
|
||||
) -> bytes:
|
||||
"""Sets up an SSL communication channel.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
host: Server host name.
|
||||
ssl_options: SSL and TLS connection options (see
|
||||
`network.MySQLSocket.build_ssl_context`).
|
||||
charset: Client charset (see [1]), only the lower 8-bits.
|
||||
client_flags: Integer representing client capabilities flags.
|
||||
max_allowed_packet: Maximum packet size.
|
||||
|
||||
Returns:
|
||||
ssl_request_payload: Payload used to carry out SSL authentication.
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\
|
||||
page_protocol_basic_character_set.html#a_protocol_character_set
|
||||
"""
|
||||
if ssl_options is None:
|
||||
ssl_options = {}
|
||||
|
||||
# SSL connection request packet
|
||||
ssl_request_payload = MySQLProtocol.make_auth_ssl(
|
||||
charset=charset,
|
||||
client_flags=client_flags,
|
||||
max_allowed_packet=max_allowed_packet,
|
||||
)
|
||||
sock.send(ssl_request_payload)
|
||||
|
||||
logger.debug("Building SSL context")
|
||||
ssl_context = sock.build_ssl_context(
|
||||
ssl_ca=ssl_options.get("ca"),
|
||||
ssl_cert=ssl_options.get("cert"),
|
||||
ssl_key=ssl_options.get("key"),
|
||||
ssl_verify_cert=ssl_options.get("verify_cert", False),
|
||||
ssl_verify_identity=ssl_options.get("verify_identity", False),
|
||||
tls_versions=ssl_options.get("tls_versions"),
|
||||
tls_cipher_suites=ssl_options.get("tls_ciphersuites"),
|
||||
)
|
||||
|
||||
logger.debug("Switching to SSL")
|
||||
sock.switch_to_ssl(ssl_context, host)
|
||||
|
||||
logger.debug("SSL has been enabled")
|
||||
self._ssl_enabled = True
|
||||
|
||||
return ssl_request_payload
|
||||
|
||||
def _switch_auth_strategy(
|
||||
self,
|
||||
new_strategy_name: str,
|
||||
strategy_class: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password_factor: int = 1,
|
||||
) -> None:
|
||||
"""Switches the authorization plugin.
|
||||
|
||||
Args:
|
||||
new_strategy_name: New authorization plugin name to switch to.
|
||||
strategy_class: New authorization plugin class to switch to
|
||||
(has higher precedence than the authorization plugin name).
|
||||
username: Username to be used - if not defined, the username
|
||||
provided when `authentication()` was invoked is used.
|
||||
password_factor: Up to three levels of authentication (MFA) are allowed,
|
||||
hence you can choose the password corresponding to the 1st,
|
||||
2nd, or 3rd factor - 1st is the default.
|
||||
"""
|
||||
if username is None:
|
||||
username = self._username
|
||||
|
||||
if strategy_class is None:
|
||||
strategy_class = self._auth_plugin_class
|
||||
|
||||
logger.debug("Switching to strategy %s", new_strategy_name)
|
||||
self._auth_strategy = get_auth_plugin(
|
||||
plugin_name=new_strategy_name, auth_plugin_class=strategy_class
|
||||
)(
|
||||
username,
|
||||
self._passwords.get(password_factor, ""),
|
||||
ssl_enabled=self.ssl_enabled,
|
||||
)
|
||||
|
||||
def _mfa_n_factor(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
pkt: bytes,
|
||||
) -> Optional[bytes]:
|
||||
"""Handles MFA (Multi-Factor Authentication) response.
|
||||
|
||||
Up to three levels of authentication (MFA) are allowed.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
pkt: MFA response.
|
||||
|
||||
Returns:
|
||||
ok_packet: If last server's response is an OK packet.
|
||||
None: If last server's response isn't an OK packet and no ERROR was raised.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If got an invalid N factor.
|
||||
errors.ErrorTypes: If got an ERROR response.
|
||||
"""
|
||||
n_factor = 2
|
||||
while pkt[4] == MFA_STATUS:
|
||||
if n_factor not in self._passwords:
|
||||
raise InterfaceError(
|
||||
"Failed Multi Factor Authentication (invalid N factor)"
|
||||
)
|
||||
|
||||
new_strategy_name, auth_data = MySQLProtocol.parse_auth_next_factor(pkt)
|
||||
self._switch_auth_strategy(new_strategy_name, password_factor=n_factor)
|
||||
logger.debug("MFA %i factor %s", n_factor, self._auth_strategy.name)
|
||||
|
||||
pkt = self._auth_strategy.auth_switch_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == EXCHANGE_FURTHER_STATUS:
|
||||
auth_data = MySQLProtocol.parse_auth_more_data(pkt)
|
||||
pkt = self._auth_strategy.auth_more_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == OK_STATUS:
|
||||
logger.debug("MFA completed succesfully")
|
||||
return pkt
|
||||
|
||||
if pkt[4] == ERR_STATUS:
|
||||
raise get_exception(pkt)
|
||||
|
||||
n_factor += 1
|
||||
|
||||
logger.warning("MFA terminated with a no ok packet")
|
||||
return None
|
||||
|
||||
def _handle_server_response(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
pkt: bytes,
|
||||
) -> Optional[bytes]:
|
||||
"""Handles server's response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
pkt: Server's response after completing the `HandShakeResponse`.
|
||||
|
||||
Returns:
|
||||
ok_packet: If last server's response is an OK packet.
|
||||
None: If last server's response isn't an OK packet and no ERROR was raised.
|
||||
|
||||
Raises:
|
||||
errors.ErrorTypes: If got an ERROR response.
|
||||
NotSupportedError: If got Authentication with old (insecure) passwords.
|
||||
"""
|
||||
if pkt[4] == AUTH_SWITCH_STATUS and len(pkt) == 5:
|
||||
raise NotSupportedError(
|
||||
"Authentication with old (insecure) passwords "
|
||||
"is not supported. For more information, lookup "
|
||||
"Password Hashing in the latest MySQL manual"
|
||||
)
|
||||
|
||||
if pkt[4] == AUTH_SWITCH_STATUS:
|
||||
logger.debug("Server's response is an auth switch request")
|
||||
new_strategy_name, auth_data = MySQLProtocol.parse_auth_switch_request(pkt)
|
||||
self._switch_auth_strategy(new_strategy_name)
|
||||
pkt = self._auth_strategy.auth_switch_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == EXCHANGE_FURTHER_STATUS:
|
||||
logger.debug("Exchanging further packets")
|
||||
auth_data = MySQLProtocol.parse_auth_more_data(pkt)
|
||||
pkt = self._auth_strategy.auth_more_response(
|
||||
sock, auth_data, **self._plugin_config
|
||||
)
|
||||
|
||||
if pkt[4] == OK_STATUS:
|
||||
logger.debug("%s completed succesfully", self._auth_strategy.name)
|
||||
return pkt
|
||||
|
||||
if pkt[4] == MFA_STATUS:
|
||||
logger.debug("Starting multi-factor authentication")
|
||||
logger.debug("MFA 1 factor %s", self._auth_strategy.name)
|
||||
return self._mfa_n_factor(sock, pkt)
|
||||
|
||||
if pkt[4] == ERR_STATUS:
|
||||
raise get_exception(pkt)
|
||||
|
||||
return None
|
||||
|
||||
def authenticate(
|
||||
self,
|
||||
sock: MySQLSocket,
|
||||
handshake: HandShakeType,
|
||||
username: str = "",
|
||||
password1: str = "",
|
||||
password2: str = "",
|
||||
password3: str = "",
|
||||
database: Optional[str] = None,
|
||||
charset: int = DEFAULT_CHARSET_ID,
|
||||
client_flags: int = 0,
|
||||
max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET,
|
||||
auth_plugin: Optional[str] = None,
|
||||
auth_plugin_class: Optional[str] = None,
|
||||
conn_attrs: Optional[Dict[str, str]] = None,
|
||||
is_change_user_request: bool = False,
|
||||
read_timeout: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> bytes:
|
||||
"""Performs the authentication phase.
|
||||
|
||||
During re-authentication you must set `is_change_user_request` to True.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
handshake: Initial handshake.
|
||||
username: Account's username.
|
||||
password1: Account's password factor 1.
|
||||
password2: Account's password factor 2.
|
||||
password3: Account's password factor 3.
|
||||
database: Initial database name for the connection.
|
||||
charset: Client charset (see [1]), only the lower 8-bits.
|
||||
client_flags: Integer representing client capabilities flags.
|
||||
max_allowed_packet: Maximum packet size.
|
||||
auth_plugin: Authorization plugin name.
|
||||
auth_plugin_class: Authorization plugin class (has higher precedence
|
||||
than the authorization plugin name).
|
||||
conn_attrs: Connection attributes.
|
||||
is_change_user_request: Whether is a `change user request` operation or not.
|
||||
read_timeout: Timeout in seconds upto which the connector should wait for
|
||||
the server to reply back before raising an ReadTimeoutError.
|
||||
write_timeout: Timeout in seconds upto which the connector should spend to
|
||||
send data to the server before raising an WriteTimeoutError.
|
||||
Returns:
|
||||
ok_packet: OK packet.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If OK packet is NULL.
|
||||
ReadTimeoutError: If the time taken for the server to reply back exceeds
|
||||
'read_timeout' (if set).
|
||||
WriteTimeoutError: If the time taken to send data packets to the server
|
||||
exceeds 'write_timeout' (if set).
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\
|
||||
page_protocol_basic_character_set.html#a_protocol_character_set
|
||||
"""
|
||||
# update credentials, plugin config and plugin class
|
||||
self._username = username
|
||||
self._passwords = {1: password1, 2: password2, 3: password3}
|
||||
self._auth_plugin_class = auth_plugin_class
|
||||
|
||||
# client's handshake response
|
||||
response_payload, self._auth_strategy = MySQLProtocol.make_auth(
|
||||
handshake=handshake,
|
||||
username=username,
|
||||
password=password1,
|
||||
database=database,
|
||||
charset=charset,
|
||||
client_flags=client_flags,
|
||||
max_allowed_packet=max_allowed_packet,
|
||||
auth_plugin=auth_plugin,
|
||||
auth_plugin_class=auth_plugin_class,
|
||||
conn_attrs=conn_attrs,
|
||||
is_change_user_request=is_change_user_request,
|
||||
ssl_enabled=self.ssl_enabled,
|
||||
plugin_config=self.plugin_config,
|
||||
)
|
||||
|
||||
# client sends transaction response
|
||||
send_args = (
|
||||
(0, 0, write_timeout)
|
||||
if is_change_user_request
|
||||
else (None, None, write_timeout)
|
||||
)
|
||||
sock.send(response_payload, *send_args)
|
||||
|
||||
# server replies back
|
||||
pkt = bytes(sock.recv(read_timeout))
|
||||
|
||||
ok_pkt = self._handle_server_response(sock, pkt)
|
||||
if ok_pkt is None:
|
||||
raise InterfaceError("Got a NULL ok_pkt") from None
|
||||
|
||||
return ok_pkt
|
||||
@@ -0,0 +1,620 @@
|
||||
# -*- coding: utf-8 -*- # pylint: disable=missing-module-docstring
|
||||
|
||||
# Copyright (c) 2013, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
"""This module contains the MySQL Server Character Sets.""" # pylint: disable=pointless-string-statement
|
||||
|
||||
# This file was auto-generated.
|
||||
_GENERATED_ON: str = "2022-05-09"
|
||||
_MYSQL_VERSION: Tuple[int, int, int] = (8, 0, 30)
|
||||
|
||||
MYSQL_CHARACTER_SETS: List[Optional[Tuple[str, str, bool]]] = [
|
||||
# (character set name, collation, default)
|
||||
None,
|
||||
("big5", "big5_chinese_ci", True), # 1
|
||||
("latin2", "latin2_czech_cs", False), # 2
|
||||
("dec8", "dec8_swedish_ci", True), # 3
|
||||
("cp850", "cp850_general_ci", True), # 4
|
||||
("latin1", "latin1_german1_ci", False), # 5
|
||||
("hp8", "hp8_english_ci", True), # 6
|
||||
("koi8r", "koi8r_general_ci", True), # 7
|
||||
("latin1", "latin1_swedish_ci", True), # 8
|
||||
("latin2", "latin2_general_ci", True), # 9
|
||||
("swe7", "swe7_swedish_ci", True), # 10
|
||||
("ascii", "ascii_general_ci", True), # 11
|
||||
("ujis", "ujis_japanese_ci", True), # 12
|
||||
("sjis", "sjis_japanese_ci", True), # 13
|
||||
("cp1251", "cp1251_bulgarian_ci", False), # 14
|
||||
("latin1", "latin1_danish_ci", False), # 15
|
||||
("hebrew", "hebrew_general_ci", True), # 16
|
||||
None,
|
||||
("tis620", "tis620_thai_ci", True), # 18
|
||||
("euckr", "euckr_korean_ci", True), # 19
|
||||
("latin7", "latin7_estonian_cs", False), # 20
|
||||
("latin2", "latin2_hungarian_ci", False), # 21
|
||||
("koi8u", "koi8u_general_ci", True), # 22
|
||||
("cp1251", "cp1251_ukrainian_ci", False), # 23
|
||||
("gb2312", "gb2312_chinese_ci", True), # 24
|
||||
("greek", "greek_general_ci", True), # 25
|
||||
("cp1250", "cp1250_general_ci", True), # 26
|
||||
("latin2", "latin2_croatian_ci", False), # 27
|
||||
("gbk", "gbk_chinese_ci", True), # 28
|
||||
("cp1257", "cp1257_lithuanian_ci", False), # 29
|
||||
("latin5", "latin5_turkish_ci", True), # 30
|
||||
("latin1", "latin1_german2_ci", False), # 31
|
||||
("armscii8", "armscii8_general_ci", True), # 32
|
||||
("utf8mb3", "utf8mb3_general_ci", True), # 33
|
||||
("cp1250", "cp1250_czech_cs", False), # 34
|
||||
("ucs2", "ucs2_general_ci", True), # 35
|
||||
("cp866", "cp866_general_ci", True), # 36
|
||||
("keybcs2", "keybcs2_general_ci", True), # 37
|
||||
("macce", "macce_general_ci", True), # 38
|
||||
("macroman", "macroman_general_ci", True), # 39
|
||||
("cp852", "cp852_general_ci", True), # 40
|
||||
("latin7", "latin7_general_ci", True), # 41
|
||||
("latin7", "latin7_general_cs", False), # 42
|
||||
("macce", "macce_bin", False), # 43
|
||||
("cp1250", "cp1250_croatian_ci", False), # 44
|
||||
("utf8mb4", "utf8mb4_general_ci", False), # 45
|
||||
("utf8mb4", "utf8mb4_bin", False), # 46
|
||||
("latin1", "latin1_bin", False), # 47
|
||||
("latin1", "latin1_general_ci", False), # 48
|
||||
("latin1", "latin1_general_cs", False), # 49
|
||||
("cp1251", "cp1251_bin", False), # 50
|
||||
("cp1251", "cp1251_general_ci", True), # 51
|
||||
("cp1251", "cp1251_general_cs", False), # 52
|
||||
("macroman", "macroman_bin", False), # 53
|
||||
("utf16", "utf16_general_ci", True), # 54
|
||||
("utf16", "utf16_bin", False), # 55
|
||||
("utf16le", "utf16le_general_ci", True), # 56
|
||||
("cp1256", "cp1256_general_ci", True), # 57
|
||||
("cp1257", "cp1257_bin", False), # 58
|
||||
("cp1257", "cp1257_general_ci", True), # 59
|
||||
("utf32", "utf32_general_ci", True), # 60
|
||||
("utf32", "utf32_bin", False), # 61
|
||||
("utf16le", "utf16le_bin", False), # 62
|
||||
("binary", "binary", True), # 63
|
||||
("armscii8", "armscii8_bin", False), # 64
|
||||
("ascii", "ascii_bin", False), # 65
|
||||
("cp1250", "cp1250_bin", False), # 66
|
||||
("cp1256", "cp1256_bin", False), # 67
|
||||
("cp866", "cp866_bin", False), # 68
|
||||
("dec8", "dec8_bin", False), # 69
|
||||
("greek", "greek_bin", False), # 70
|
||||
("hebrew", "hebrew_bin", False), # 71
|
||||
("hp8", "hp8_bin", False), # 72
|
||||
("keybcs2", "keybcs2_bin", False), # 73
|
||||
("koi8r", "koi8r_bin", False), # 74
|
||||
("koi8u", "koi8u_bin", False), # 75
|
||||
("utf8mb3", "utf8mb3_tolower_ci", False), # 76
|
||||
("latin2", "latin2_bin", False), # 77
|
||||
("latin5", "latin5_bin", False), # 78
|
||||
("latin7", "latin7_bin", False), # 79
|
||||
("cp850", "cp850_bin", False), # 80
|
||||
("cp852", "cp852_bin", False), # 81
|
||||
("swe7", "swe7_bin", False), # 82
|
||||
("utf8mb3", "utf8mb3_bin", False), # 83
|
||||
("big5", "big5_bin", False), # 84
|
||||
("euckr", "euckr_bin", False), # 85
|
||||
("gb2312", "gb2312_bin", False), # 86
|
||||
("gbk", "gbk_bin", False), # 87
|
||||
("sjis", "sjis_bin", False), # 88
|
||||
("tis620", "tis620_bin", False), # 89
|
||||
("ucs2", "ucs2_bin", False), # 90
|
||||
("ujis", "ujis_bin", False), # 91
|
||||
("geostd8", "geostd8_general_ci", True), # 92
|
||||
("geostd8", "geostd8_bin", False), # 93
|
||||
("latin1", "latin1_spanish_ci", False), # 94
|
||||
("cp932", "cp932_japanese_ci", True), # 95
|
||||
("cp932", "cp932_bin", False), # 96
|
||||
("eucjpms", "eucjpms_japanese_ci", True), # 97
|
||||
("eucjpms", "eucjpms_bin", False), # 98
|
||||
("cp1250", "cp1250_polish_ci", False), # 99
|
||||
None,
|
||||
("utf16", "utf16_unicode_ci", False), # 101
|
||||
("utf16", "utf16_icelandic_ci", False), # 102
|
||||
("utf16", "utf16_latvian_ci", False), # 103
|
||||
("utf16", "utf16_romanian_ci", False), # 104
|
||||
("utf16", "utf16_slovenian_ci", False), # 105
|
||||
("utf16", "utf16_polish_ci", False), # 106
|
||||
("utf16", "utf16_estonian_ci", False), # 107
|
||||
("utf16", "utf16_spanish_ci", False), # 108
|
||||
("utf16", "utf16_swedish_ci", False), # 109
|
||||
("utf16", "utf16_turkish_ci", False), # 110
|
||||
("utf16", "utf16_czech_ci", False), # 111
|
||||
("utf16", "utf16_danish_ci", False), # 112
|
||||
("utf16", "utf16_lithuanian_ci", False), # 113
|
||||
("utf16", "utf16_slovak_ci", False), # 114
|
||||
("utf16", "utf16_spanish2_ci", False), # 115
|
||||
("utf16", "utf16_roman_ci", False), # 116
|
||||
("utf16", "utf16_persian_ci", False), # 117
|
||||
("utf16", "utf16_esperanto_ci", False), # 118
|
||||
("utf16", "utf16_hungarian_ci", False), # 119
|
||||
("utf16", "utf16_sinhala_ci", False), # 120
|
||||
("utf16", "utf16_german2_ci", False), # 121
|
||||
("utf16", "utf16_croatian_ci", False), # 122
|
||||
("utf16", "utf16_unicode_520_ci", False), # 123
|
||||
("utf16", "utf16_vietnamese_ci", False), # 124
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("ucs2", "ucs2_unicode_ci", False), # 128
|
||||
("ucs2", "ucs2_icelandic_ci", False), # 129
|
||||
("ucs2", "ucs2_latvian_ci", False), # 130
|
||||
("ucs2", "ucs2_romanian_ci", False), # 131
|
||||
("ucs2", "ucs2_slovenian_ci", False), # 132
|
||||
("ucs2", "ucs2_polish_ci", False), # 133
|
||||
("ucs2", "ucs2_estonian_ci", False), # 134
|
||||
("ucs2", "ucs2_spanish_ci", False), # 135
|
||||
("ucs2", "ucs2_swedish_ci", False), # 136
|
||||
("ucs2", "ucs2_turkish_ci", False), # 137
|
||||
("ucs2", "ucs2_czech_ci", False), # 138
|
||||
("ucs2", "ucs2_danish_ci", False), # 139
|
||||
("ucs2", "ucs2_lithuanian_ci", False), # 140
|
||||
("ucs2", "ucs2_slovak_ci", False), # 141
|
||||
("ucs2", "ucs2_spanish2_ci", False), # 142
|
||||
("ucs2", "ucs2_roman_ci", False), # 143
|
||||
("ucs2", "ucs2_persian_ci", False), # 144
|
||||
("ucs2", "ucs2_esperanto_ci", False), # 145
|
||||
("ucs2", "ucs2_hungarian_ci", False), # 146
|
||||
("ucs2", "ucs2_sinhala_ci", False), # 147
|
||||
("ucs2", "ucs2_german2_ci", False), # 148
|
||||
("ucs2", "ucs2_croatian_ci", False), # 149
|
||||
("ucs2", "ucs2_unicode_520_ci", False), # 150
|
||||
("ucs2", "ucs2_vietnamese_ci", False), # 151
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("ucs2", "ucs2_general_mysql500_ci", False), # 159
|
||||
("utf32", "utf32_unicode_ci", False), # 160
|
||||
("utf32", "utf32_icelandic_ci", False), # 161
|
||||
("utf32", "utf32_latvian_ci", False), # 162
|
||||
("utf32", "utf32_romanian_ci", False), # 163
|
||||
("utf32", "utf32_slovenian_ci", False), # 164
|
||||
("utf32", "utf32_polish_ci", False), # 165
|
||||
("utf32", "utf32_estonian_ci", False), # 166
|
||||
("utf32", "utf32_spanish_ci", False), # 167
|
||||
("utf32", "utf32_swedish_ci", False), # 168
|
||||
("utf32", "utf32_turkish_ci", False), # 169
|
||||
("utf32", "utf32_czech_ci", False), # 170
|
||||
("utf32", "utf32_danish_ci", False), # 171
|
||||
("utf32", "utf32_lithuanian_ci", False), # 172
|
||||
("utf32", "utf32_slovak_ci", False), # 173
|
||||
("utf32", "utf32_spanish2_ci", False), # 174
|
||||
("utf32", "utf32_roman_ci", False), # 175
|
||||
("utf32", "utf32_persian_ci", False), # 176
|
||||
("utf32", "utf32_esperanto_ci", False), # 177
|
||||
("utf32", "utf32_hungarian_ci", False), # 178
|
||||
("utf32", "utf32_sinhala_ci", False), # 179
|
||||
("utf32", "utf32_german2_ci", False), # 180
|
||||
("utf32", "utf32_croatian_ci", False), # 181
|
||||
("utf32", "utf32_unicode_520_ci", False), # 182
|
||||
("utf32", "utf32_vietnamese_ci", False), # 183
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("utf8mb3", "utf8mb3_unicode_ci", False), # 192
|
||||
("utf8mb3", "utf8mb3_icelandic_ci", False), # 193
|
||||
("utf8mb3", "utf8mb3_latvian_ci", False), # 194
|
||||
("utf8mb3", "utf8mb3_romanian_ci", False), # 195
|
||||
("utf8mb3", "utf8mb3_slovenian_ci", False), # 196
|
||||
("utf8mb3", "utf8mb3_polish_ci", False), # 197
|
||||
("utf8mb3", "utf8mb3_estonian_ci", False), # 198
|
||||
("utf8mb3", "utf8mb3_spanish_ci", False), # 199
|
||||
("utf8mb3", "utf8mb3_swedish_ci", False), # 200
|
||||
("utf8mb3", "utf8mb3_turkish_ci", False), # 201
|
||||
("utf8mb3", "utf8mb3_czech_ci", False), # 202
|
||||
("utf8mb3", "utf8mb3_danish_ci", False), # 203
|
||||
("utf8mb3", "utf8mb3_lithuanian_ci", False), # 204
|
||||
("utf8mb3", "utf8mb3_slovak_ci", False), # 205
|
||||
("utf8mb3", "utf8mb3_spanish2_ci", False), # 206
|
||||
("utf8mb3", "utf8mb3_roman_ci", False), # 207
|
||||
("utf8mb3", "utf8mb3_persian_ci", False), # 208
|
||||
("utf8mb3", "utf8mb3_esperanto_ci", False), # 209
|
||||
("utf8mb3", "utf8mb3_hungarian_ci", False), # 210
|
||||
("utf8mb3", "utf8mb3_sinhala_ci", False), # 211
|
||||
("utf8mb3", "utf8mb3_german2_ci", False), # 212
|
||||
("utf8mb3", "utf8mb3_croatian_ci", False), # 213
|
||||
("utf8mb3", "utf8mb3_unicode_520_ci", False), # 214
|
||||
("utf8mb3", "utf8mb3_vietnamese_ci", False), # 215
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("utf8mb3", "utf8mb3_general_mysql500_ci", False), # 223
|
||||
("utf8mb4", "utf8mb4_unicode_ci", False), # 224
|
||||
("utf8mb4", "utf8mb4_icelandic_ci", False), # 225
|
||||
("utf8mb4", "utf8mb4_latvian_ci", False), # 226
|
||||
("utf8mb4", "utf8mb4_romanian_ci", False), # 227
|
||||
("utf8mb4", "utf8mb4_slovenian_ci", False), # 228
|
||||
("utf8mb4", "utf8mb4_polish_ci", False), # 229
|
||||
("utf8mb4", "utf8mb4_estonian_ci", False), # 230
|
||||
("utf8mb4", "utf8mb4_spanish_ci", False), # 231
|
||||
("utf8mb4", "utf8mb4_swedish_ci", False), # 232
|
||||
("utf8mb4", "utf8mb4_turkish_ci", False), # 233
|
||||
("utf8mb4", "utf8mb4_czech_ci", False), # 234
|
||||
("utf8mb4", "utf8mb4_danish_ci", False), # 235
|
||||
("utf8mb4", "utf8mb4_lithuanian_ci", False), # 236
|
||||
("utf8mb4", "utf8mb4_slovak_ci", False), # 237
|
||||
("utf8mb4", "utf8mb4_spanish2_ci", False), # 238
|
||||
("utf8mb4", "utf8mb4_roman_ci", False), # 239
|
||||
("utf8mb4", "utf8mb4_persian_ci", False), # 240
|
||||
("utf8mb4", "utf8mb4_esperanto_ci", False), # 241
|
||||
("utf8mb4", "utf8mb4_hungarian_ci", False), # 242
|
||||
("utf8mb4", "utf8mb4_sinhala_ci", False), # 243
|
||||
("utf8mb4", "utf8mb4_german2_ci", False), # 244
|
||||
("utf8mb4", "utf8mb4_croatian_ci", False), # 245
|
||||
("utf8mb4", "utf8mb4_unicode_520_ci", False), # 246
|
||||
("utf8mb4", "utf8mb4_vietnamese_ci", False), # 247
|
||||
("gb18030", "gb18030_chinese_ci", True), # 248
|
||||
("gb18030", "gb18030_bin", False), # 249
|
||||
("gb18030", "gb18030_unicode_520_ci", False), # 250
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("utf8mb4", "utf8mb4_0900_ai_ci", True), # 255
|
||||
("utf8mb4", "utf8mb4_de_pb_0900_ai_ci", False), # 256
|
||||
("utf8mb4", "utf8mb4_is_0900_ai_ci", False), # 257
|
||||
("utf8mb4", "utf8mb4_lv_0900_ai_ci", False), # 258
|
||||
("utf8mb4", "utf8mb4_ro_0900_ai_ci", False), # 259
|
||||
("utf8mb4", "utf8mb4_sl_0900_ai_ci", False), # 260
|
||||
("utf8mb4", "utf8mb4_pl_0900_ai_ci", False), # 261
|
||||
("utf8mb4", "utf8mb4_et_0900_ai_ci", False), # 262
|
||||
("utf8mb4", "utf8mb4_es_0900_ai_ci", False), # 263
|
||||
("utf8mb4", "utf8mb4_sv_0900_ai_ci", False), # 264
|
||||
("utf8mb4", "utf8mb4_tr_0900_ai_ci", False), # 265
|
||||
("utf8mb4", "utf8mb4_cs_0900_ai_ci", False), # 266
|
||||
("utf8mb4", "utf8mb4_da_0900_ai_ci", False), # 267
|
||||
("utf8mb4", "utf8mb4_lt_0900_ai_ci", False), # 268
|
||||
("utf8mb4", "utf8mb4_sk_0900_ai_ci", False), # 269
|
||||
("utf8mb4", "utf8mb4_es_trad_0900_ai_ci", False), # 270
|
||||
("utf8mb4", "utf8mb4_la_0900_ai_ci", False), # 271
|
||||
None,
|
||||
("utf8mb4", "utf8mb4_eo_0900_ai_ci", False), # 273
|
||||
("utf8mb4", "utf8mb4_hu_0900_ai_ci", False), # 274
|
||||
("utf8mb4", "utf8mb4_hr_0900_ai_ci", False), # 275
|
||||
None,
|
||||
("utf8mb4", "utf8mb4_vi_0900_ai_ci", False), # 277
|
||||
("utf8mb4", "utf8mb4_0900_as_cs", False), # 278
|
||||
("utf8mb4", "utf8mb4_de_pb_0900_as_cs", False), # 279
|
||||
("utf8mb4", "utf8mb4_is_0900_as_cs", False), # 280
|
||||
("utf8mb4", "utf8mb4_lv_0900_as_cs", False), # 281
|
||||
("utf8mb4", "utf8mb4_ro_0900_as_cs", False), # 282
|
||||
("utf8mb4", "utf8mb4_sl_0900_as_cs", False), # 283
|
||||
("utf8mb4", "utf8mb4_pl_0900_as_cs", False), # 284
|
||||
("utf8mb4", "utf8mb4_et_0900_as_cs", False), # 285
|
||||
("utf8mb4", "utf8mb4_es_0900_as_cs", False), # 286
|
||||
("utf8mb4", "utf8mb4_sv_0900_as_cs", False), # 287
|
||||
("utf8mb4", "utf8mb4_tr_0900_as_cs", False), # 288
|
||||
("utf8mb4", "utf8mb4_cs_0900_as_cs", False), # 289
|
||||
("utf8mb4", "utf8mb4_da_0900_as_cs", False), # 290
|
||||
("utf8mb4", "utf8mb4_lt_0900_as_cs", False), # 291
|
||||
("utf8mb4", "utf8mb4_sk_0900_as_cs", False), # 292
|
||||
("utf8mb4", "utf8mb4_es_trad_0900_as_cs", False), # 293
|
||||
("utf8mb4", "utf8mb4_la_0900_as_cs", False), # 294
|
||||
None,
|
||||
("utf8mb4", "utf8mb4_eo_0900_as_cs", False), # 296
|
||||
("utf8mb4", "utf8mb4_hu_0900_as_cs", False), # 297
|
||||
("utf8mb4", "utf8mb4_hr_0900_as_cs", False), # 298
|
||||
None,
|
||||
("utf8mb4", "utf8mb4_vi_0900_as_cs", False), # 300
|
||||
None,
|
||||
None,
|
||||
("utf8mb4", "utf8mb4_ja_0900_as_cs", False), # 303
|
||||
("utf8mb4", "utf8mb4_ja_0900_as_cs_ks", False), # 304
|
||||
("utf8mb4", "utf8mb4_0900_as_ci", False), # 305
|
||||
("utf8mb4", "utf8mb4_ru_0900_ai_ci", False), # 306
|
||||
("utf8mb4", "utf8mb4_ru_0900_as_cs", False), # 307
|
||||
("utf8mb4", "utf8mb4_zh_0900_as_cs", False), # 308
|
||||
("utf8mb4", "utf8mb4_0900_bin", False), # 309
|
||||
("utf8mb4", "utf8mb4_nb_0900_ai_ci", False), # 310
|
||||
("utf8mb4", "utf8mb4_nb_0900_as_cs", False), # 311
|
||||
("utf8mb4", "utf8mb4_nn_0900_ai_ci", False), # 312
|
||||
("utf8mb4", "utf8mb4_nn_0900_as_cs", False), # 313
|
||||
("utf8mb4", "utf8mb4_sr_latn_0900_ai_ci", False), # 314
|
||||
("utf8mb4", "utf8mb4_sr_latn_0900_as_cs", False), # 315
|
||||
("utf8mb4", "utf8mb4_bs_0900_ai_ci", False), # 316
|
||||
("utf8mb4", "utf8mb4_bs_0900_as_cs", False), # 317
|
||||
("utf8mb4", "utf8mb4_bg_0900_ai_ci", False), # 318
|
||||
("utf8mb4", "utf8mb4_bg_0900_as_cs", False), # 319
|
||||
("utf8mb4", "utf8mb4_gl_0900_ai_ci", False), # 320
|
||||
("utf8mb4", "utf8mb4_gl_0900_as_cs", False), # 321
|
||||
("utf8mb4", "utf8mb4_mn_cyrl_0900_ai_ci", False), # 322
|
||||
("utf8mb4", "utf8mb4_mn_cyrl_0900_as_cs", False), # 323
|
||||
]
|
||||
|
||||
MYSQL_CHARACTER_SETS_57: List[Optional[Tuple[str, str, bool]]] = [
|
||||
# (character set name, collation, default)
|
||||
None,
|
||||
("big5", "big5_chinese_ci", True), # 1
|
||||
("latin2", "latin2_czech_cs", False), # 2
|
||||
("dec8", "dec8_swedish_ci", True), # 3
|
||||
("cp850", "cp850_general_ci", True), # 4
|
||||
("latin1", "latin1_german1_ci", False), # 5
|
||||
("hp8", "hp8_english_ci", True), # 6
|
||||
("koi8r", "koi8r_general_ci", True), # 7
|
||||
("latin1", "latin1_swedish_ci", True), # 8
|
||||
("latin2", "latin2_general_ci", True), # 9
|
||||
("swe7", "swe7_swedish_ci", True), # 10
|
||||
("ascii", "ascii_general_ci", True), # 11
|
||||
("ujis", "ujis_japanese_ci", True), # 12
|
||||
("sjis", "sjis_japanese_ci", True), # 13
|
||||
("cp1251", "cp1251_bulgarian_ci", False), # 14
|
||||
("latin1", "latin1_danish_ci", False), # 15
|
||||
("hebrew", "hebrew_general_ci", True), # 16
|
||||
None,
|
||||
("tis620", "tis620_thai_ci", True), # 18
|
||||
("euckr", "euckr_korean_ci", True), # 19
|
||||
("latin7", "latin7_estonian_cs", False), # 20
|
||||
("latin2", "latin2_hungarian_ci", False), # 21
|
||||
("koi8u", "koi8u_general_ci", True), # 22
|
||||
("cp1251", "cp1251_ukrainian_ci", False), # 23
|
||||
("gb2312", "gb2312_chinese_ci", True), # 24
|
||||
("greek", "greek_general_ci", True), # 25
|
||||
("cp1250", "cp1250_general_ci", True), # 26
|
||||
("latin2", "latin2_croatian_ci", False), # 27
|
||||
("gbk", "gbk_chinese_ci", True), # 28
|
||||
("cp1257", "cp1257_lithuanian_ci", False), # 29
|
||||
("latin5", "latin5_turkish_ci", True), # 30
|
||||
("latin1", "latin1_german2_ci", False), # 31
|
||||
("armscii8", "armscii8_general_ci", True), # 32
|
||||
("utf8", "utf8_general_ci", True), # 33
|
||||
("cp1250", "cp1250_czech_cs", False), # 34
|
||||
("ucs2", "ucs2_general_ci", True), # 35
|
||||
("cp866", "cp866_general_ci", True), # 36
|
||||
("keybcs2", "keybcs2_general_ci", True), # 37
|
||||
("macce", "macce_general_ci", True), # 38
|
||||
("macroman", "macroman_general_ci", True), # 39
|
||||
("cp852", "cp852_general_ci", True), # 40
|
||||
("latin7", "latin7_general_ci", True), # 41
|
||||
("latin7", "latin7_general_cs", False), # 42
|
||||
("macce", "macce_bin", False), # 43
|
||||
("cp1250", "cp1250_croatian_ci", False), # 44
|
||||
("utf8mb4", "utf8mb4_general_ci", True), # 45
|
||||
("utf8mb4", "utf8mb4_bin", False), # 46
|
||||
("latin1", "latin1_bin", False), # 47
|
||||
("latin1", "latin1_general_ci", False), # 48
|
||||
("latin1", "latin1_general_cs", False), # 49
|
||||
("cp1251", "cp1251_bin", False), # 50
|
||||
("cp1251", "cp1251_general_ci", True), # 51
|
||||
("cp1251", "cp1251_general_cs", False), # 52
|
||||
("macroman", "macroman_bin", False), # 53
|
||||
("utf16", "utf16_general_ci", True), # 54
|
||||
("utf16", "utf16_bin", False), # 55
|
||||
("utf16le", "utf16le_general_ci", True), # 56
|
||||
("cp1256", "cp1256_general_ci", True), # 57
|
||||
("cp1257", "cp1257_bin", False), # 58
|
||||
("cp1257", "cp1257_general_ci", True), # 59
|
||||
("utf32", "utf32_general_ci", True), # 60
|
||||
("utf32", "utf32_bin", False), # 61
|
||||
("utf16le", "utf16le_bin", False), # 62
|
||||
("binary", "binary", True), # 63
|
||||
("armscii8", "armscii8_bin", False), # 64
|
||||
("ascii", "ascii_bin", False), # 65
|
||||
("cp1250", "cp1250_bin", False), # 66
|
||||
("cp1256", "cp1256_bin", False), # 67
|
||||
("cp866", "cp866_bin", False), # 68
|
||||
("dec8", "dec8_bin", False), # 69
|
||||
("greek", "greek_bin", False), # 70
|
||||
("hebrew", "hebrew_bin", False), # 71
|
||||
("hp8", "hp8_bin", False), # 72
|
||||
("keybcs2", "keybcs2_bin", False), # 73
|
||||
("koi8r", "koi8r_bin", False), # 74
|
||||
("koi8u", "koi8u_bin", False), # 75
|
||||
None,
|
||||
("latin2", "latin2_bin", False), # 77
|
||||
("latin5", "latin5_bin", False), # 78
|
||||
("latin7", "latin7_bin", False), # 79
|
||||
("cp850", "cp850_bin", False), # 80
|
||||
("cp852", "cp852_bin", False), # 81
|
||||
("swe7", "swe7_bin", False), # 82
|
||||
("utf8", "utf8_bin", False), # 83
|
||||
("big5", "big5_bin", False), # 84
|
||||
("euckr", "euckr_bin", False), # 85
|
||||
("gb2312", "gb2312_bin", False), # 86
|
||||
("gbk", "gbk_bin", False), # 87
|
||||
("sjis", "sjis_bin", False), # 88
|
||||
("tis620", "tis620_bin", False), # 89
|
||||
("ucs2", "ucs2_bin", False), # 90
|
||||
("ujis", "ujis_bin", False), # 91
|
||||
("geostd8", "geostd8_general_ci", True), # 92
|
||||
("geostd8", "geostd8_bin", False), # 93
|
||||
("latin1", "latin1_spanish_ci", False), # 94
|
||||
("cp932", "cp932_japanese_ci", True), # 95
|
||||
("cp932", "cp932_bin", False), # 96
|
||||
("eucjpms", "eucjpms_japanese_ci", True), # 97
|
||||
("eucjpms", "eucjpms_bin", False), # 98
|
||||
("cp1250", "cp1250_polish_ci", False), # 99
|
||||
None,
|
||||
("utf16", "utf16_unicode_ci", False), # 101
|
||||
("utf16", "utf16_icelandic_ci", False), # 102
|
||||
("utf16", "utf16_latvian_ci", False), # 103
|
||||
("utf16", "utf16_romanian_ci", False), # 104
|
||||
("utf16", "utf16_slovenian_ci", False), # 105
|
||||
("utf16", "utf16_polish_ci", False), # 106
|
||||
("utf16", "utf16_estonian_ci", False), # 107
|
||||
("utf16", "utf16_spanish_ci", False), # 108
|
||||
("utf16", "utf16_swedish_ci", False), # 109
|
||||
("utf16", "utf16_turkish_ci", False), # 110
|
||||
("utf16", "utf16_czech_ci", False), # 111
|
||||
("utf16", "utf16_danish_ci", False), # 112
|
||||
("utf16", "utf16_lithuanian_ci", False), # 113
|
||||
("utf16", "utf16_slovak_ci", False), # 114
|
||||
("utf16", "utf16_spanish2_ci", False), # 115
|
||||
("utf16", "utf16_roman_ci", False), # 116
|
||||
("utf16", "utf16_persian_ci", False), # 117
|
||||
("utf16", "utf16_esperanto_ci", False), # 118
|
||||
("utf16", "utf16_hungarian_ci", False), # 119
|
||||
("utf16", "utf16_sinhala_ci", False), # 120
|
||||
("utf16", "utf16_german2_ci", False), # 121
|
||||
("utf16", "utf16_croatian_ci", False), # 122
|
||||
("utf16", "utf16_unicode_520_ci", False), # 123
|
||||
("utf16", "utf16_vietnamese_ci", False), # 124
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("ucs2", "ucs2_unicode_ci", False), # 128
|
||||
("ucs2", "ucs2_icelandic_ci", False), # 129
|
||||
("ucs2", "ucs2_latvian_ci", False), # 130
|
||||
("ucs2", "ucs2_romanian_ci", False), # 131
|
||||
("ucs2", "ucs2_slovenian_ci", False), # 132
|
||||
("ucs2", "ucs2_polish_ci", False), # 133
|
||||
("ucs2", "ucs2_estonian_ci", False), # 134
|
||||
("ucs2", "ucs2_spanish_ci", False), # 135
|
||||
("ucs2", "ucs2_swedish_ci", False), # 136
|
||||
("ucs2", "ucs2_turkish_ci", False), # 137
|
||||
("ucs2", "ucs2_czech_ci", False), # 138
|
||||
("ucs2", "ucs2_danish_ci", False), # 139
|
||||
("ucs2", "ucs2_lithuanian_ci", False), # 140
|
||||
("ucs2", "ucs2_slovak_ci", False), # 141
|
||||
("ucs2", "ucs2_spanish2_ci", False), # 142
|
||||
("ucs2", "ucs2_roman_ci", False), # 143
|
||||
("ucs2", "ucs2_persian_ci", False), # 144
|
||||
("ucs2", "ucs2_esperanto_ci", False), # 145
|
||||
("ucs2", "ucs2_hungarian_ci", False), # 146
|
||||
("ucs2", "ucs2_sinhala_ci", False), # 147
|
||||
("ucs2", "ucs2_german2_ci", False), # 148
|
||||
("ucs2", "ucs2_croatian_ci", False), # 149
|
||||
("ucs2", "ucs2_unicode_520_ci", False), # 150
|
||||
("ucs2", "ucs2_vietnamese_ci", False), # 151
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("ucs2", "ucs2_general_mysql500_ci", False), # 159
|
||||
("utf32", "utf32_unicode_ci", False), # 160
|
||||
("utf32", "utf32_icelandic_ci", False), # 161
|
||||
("utf32", "utf32_latvian_ci", False), # 162
|
||||
("utf32", "utf32_romanian_ci", False), # 163
|
||||
("utf32", "utf32_slovenian_ci", False), # 164
|
||||
("utf32", "utf32_polish_ci", False), # 165
|
||||
("utf32", "utf32_estonian_ci", False), # 166
|
||||
("utf32", "utf32_spanish_ci", False), # 167
|
||||
("utf32", "utf32_swedish_ci", False), # 168
|
||||
("utf32", "utf32_turkish_ci", False), # 169
|
||||
("utf32", "utf32_czech_ci", False), # 170
|
||||
("utf32", "utf32_danish_ci", False), # 171
|
||||
("utf32", "utf32_lithuanian_ci", False), # 172
|
||||
("utf32", "utf32_slovak_ci", False), # 173
|
||||
("utf32", "utf32_spanish2_ci", False), # 174
|
||||
("utf32", "utf32_roman_ci", False), # 175
|
||||
("utf32", "utf32_persian_ci", False), # 176
|
||||
("utf32", "utf32_esperanto_ci", False), # 177
|
||||
("utf32", "utf32_hungarian_ci", False), # 178
|
||||
("utf32", "utf32_sinhala_ci", False), # 179
|
||||
("utf32", "utf32_german2_ci", False), # 180
|
||||
("utf32", "utf32_croatian_ci", False), # 181
|
||||
("utf32", "utf32_unicode_520_ci", False), # 182
|
||||
("utf32", "utf32_vietnamese_ci", False), # 183
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("utf8", "utf8_unicode_ci", False), # 192
|
||||
("utf8", "utf8_icelandic_ci", False), # 193
|
||||
("utf8", "utf8_latvian_ci", False), # 194
|
||||
("utf8", "utf8_romanian_ci", False), # 195
|
||||
("utf8", "utf8_slovenian_ci", False), # 196
|
||||
("utf8", "utf8_polish_ci", False), # 197
|
||||
("utf8", "utf8_estonian_ci", False), # 198
|
||||
("utf8", "utf8_spanish_ci", False), # 199
|
||||
("utf8", "utf8_swedish_ci", False), # 200
|
||||
("utf8", "utf8_turkish_ci", False), # 201
|
||||
("utf8", "utf8_czech_ci", False), # 202
|
||||
("utf8", "utf8_danish_ci", False), # 203
|
||||
("utf8", "utf8_lithuanian_ci", False), # 204
|
||||
("utf8", "utf8_slovak_ci", False), # 205
|
||||
("utf8", "utf8_spanish2_ci", False), # 206
|
||||
("utf8", "utf8_roman_ci", False), # 207
|
||||
("utf8", "utf8_persian_ci", False), # 208
|
||||
("utf8", "utf8_esperanto_ci", False), # 209
|
||||
("utf8", "utf8_hungarian_ci", False), # 210
|
||||
("utf8", "utf8_sinhala_ci", False), # 211
|
||||
("utf8", "utf8_german2_ci", False), # 212
|
||||
("utf8", "utf8_croatian_ci", False), # 213
|
||||
("utf8", "utf8_unicode_520_ci", False), # 214
|
||||
("utf8", "utf8_vietnamese_ci", False), # 215
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
("utf8", "utf8_general_mysql500_ci", False), # 223
|
||||
("utf8mb4", "utf8mb4_unicode_ci", False), # 224
|
||||
("utf8mb4", "utf8mb4_icelandic_ci", False), # 225
|
||||
("utf8mb4", "utf8mb4_latvian_ci", False), # 226
|
||||
("utf8mb4", "utf8mb4_romanian_ci", False), # 227
|
||||
("utf8mb4", "utf8mb4_slovenian_ci", False), # 228
|
||||
("utf8mb4", "utf8mb4_polish_ci", False), # 229
|
||||
("utf8mb4", "utf8mb4_estonian_ci", False), # 230
|
||||
("utf8mb4", "utf8mb4_spanish_ci", False), # 231
|
||||
("utf8mb4", "utf8mb4_swedish_ci", False), # 232
|
||||
("utf8mb4", "utf8mb4_turkish_ci", False), # 233
|
||||
("utf8mb4", "utf8mb4_czech_ci", False), # 234
|
||||
("utf8mb4", "utf8mb4_danish_ci", False), # 235
|
||||
("utf8mb4", "utf8mb4_lithuanian_ci", False), # 236
|
||||
("utf8mb4", "utf8mb4_slovak_ci", False), # 237
|
||||
("utf8mb4", "utf8mb4_spanish2_ci", False), # 238
|
||||
("utf8mb4", "utf8mb4_roman_ci", False), # 239
|
||||
("utf8mb4", "utf8mb4_persian_ci", False), # 240
|
||||
("utf8mb4", "utf8mb4_esperanto_ci", False), # 241
|
||||
("utf8mb4", "utf8mb4_hungarian_ci", False), # 242
|
||||
("utf8mb4", "utf8mb4_sinhala_ci", False), # 243
|
||||
("utf8mb4", "utf8mb4_german2_ci", False), # 244
|
||||
("utf8mb4", "utf8mb4_croatian_ci", False), # 245
|
||||
("utf8mb4", "utf8mb4_unicode_520_ci", False), # 246
|
||||
("utf8mb4", "utf8mb4_vietnamese_ci", False), # 247
|
||||
("gb18030", "gb18030_chinese_ci", True), # 248
|
||||
("gb18030", "gb18030_bin", False), # 249
|
||||
("gb18030", "gb18030_unicode_520_ci", False), # 250
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,789 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Converting MySQL and Python types"""
|
||||
|
||||
import array
|
||||
import datetime
|
||||
import math
|
||||
import struct
|
||||
import time
|
||||
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from .constants import (
|
||||
MYSQL_VECTOR_TYPE_CODE,
|
||||
NATIVE_SUPPORTED_CONVERSION_TYPES,
|
||||
CharacterSet,
|
||||
FieldFlag,
|
||||
FieldType,
|
||||
SQLMode,
|
||||
)
|
||||
from .custom_types import HexLiteral
|
||||
from .types import (
|
||||
DescriptionType,
|
||||
MySQLConvertibleType,
|
||||
MySQLProducedType,
|
||||
PythonProducedType,
|
||||
StrOrBytes,
|
||||
)
|
||||
from .utils import NUMERIC_TYPES
|
||||
|
||||
CONVERT_ERROR = "Could not convert '{value}' to python {pytype}"
|
||||
|
||||
|
||||
class MySQLConverterBase:
|
||||
"""Base class for conversion classes
|
||||
|
||||
All class dealing with converting to and from MySQL data types must
|
||||
be a subclass of this class.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
charset: Optional[str] = "utf8",
|
||||
use_unicode: bool = True,
|
||||
str_fallback: bool = False,
|
||||
) -> None:
|
||||
self._character_set: CharacterSet = CharacterSet()
|
||||
self.python_types: Optional[Tuple] = None
|
||||
self.mysql_types: Optional[Tuple] = None
|
||||
self.charset: Optional[str] = None
|
||||
self.charset_id: int = 0
|
||||
self.set_charset(charset)
|
||||
self.use_unicode: bool = use_unicode
|
||||
self.str_fallback: bool = str_fallback
|
||||
self._cache_field_types: Dict[
|
||||
int,
|
||||
Callable[[bytes, DescriptionType], PythonProducedType],
|
||||
] = {}
|
||||
|
||||
def set_charset(
|
||||
self, charset: Optional[str], character_set: Optional[CharacterSet] = None
|
||||
) -> None:
|
||||
"""Set character set"""
|
||||
if charset in ("utf8mb4", "utf8mb3"):
|
||||
charset = "utf8"
|
||||
if charset is not None:
|
||||
self.charset = charset
|
||||
else:
|
||||
# default to utf8
|
||||
self.charset = "utf8"
|
||||
|
||||
if character_set:
|
||||
self._character_set = character_set
|
||||
|
||||
self.charset_id = self._character_set.get_charset_info(self.charset)[0]
|
||||
|
||||
def set_unicode(self, value: bool = True) -> None:
|
||||
"""Set whether to use Unicode"""
|
||||
self.use_unicode = value
|
||||
|
||||
def to_mysql(
|
||||
self, value: MySQLConvertibleType
|
||||
) -> Union[MySQLConvertibleType, HexLiteral]:
|
||||
"""Convert Python data type to MySQL"""
|
||||
type_name = value.__class__.__name__.lower()
|
||||
try:
|
||||
converted: MySQLConvertibleType = getattr(self, f"_{type_name}_to_mysql")(
|
||||
value
|
||||
)
|
||||
return converted
|
||||
except AttributeError:
|
||||
return value
|
||||
|
||||
def to_python(
|
||||
self, vtype: DescriptionType, value: Optional[bytes]
|
||||
) -> PythonProducedType:
|
||||
"""Convert MySQL data type to Python"""
|
||||
|
||||
if (value == b"\x00" or value is None) and vtype[1] != FieldType.BIT:
|
||||
# Don't go further when we hit a NULL value
|
||||
return None
|
||||
|
||||
if not self._cache_field_types:
|
||||
self._cache_field_types = {}
|
||||
for name, info in FieldType.desc.items():
|
||||
try:
|
||||
self._cache_field_types[info[0]] = getattr(
|
||||
self, f"_{name.lower()}_to_python"
|
||||
)
|
||||
except AttributeError:
|
||||
# We ignore field types which has no method
|
||||
pass
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return self._cache_field_types[vtype[1]](value, vtype)
|
||||
except KeyError:
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def escape(
|
||||
value: Any,
|
||||
sql_mode: Optional[Union[str, bytes]] = None, # pylint: disable=unused-argument
|
||||
) -> Any:
|
||||
"""Escape buffer for sending to MySQL"""
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def quote(buf: Any) -> StrOrBytes:
|
||||
"""Quote buffer for sending to MySQL"""
|
||||
return str(buf)
|
||||
|
||||
|
||||
class MySQLConverter(MySQLConverterBase):
|
||||
"""Default conversion class for MySQL Connector/Python.
|
||||
|
||||
o escape method: for escaping values send to MySQL
|
||||
o quoting method: for quoting values send to MySQL in statements
|
||||
o conversion mapping: maps Python and MySQL data types to
|
||||
function for converting them.
|
||||
|
||||
Whenever one needs to convert values differently, a converter_class
|
||||
argument can be given while instantiating a new connection like
|
||||
cnx.connect(converter_class=CustomMySQLConverterClass).
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
charset: Optional[str] = None,
|
||||
use_unicode: bool = True,
|
||||
str_fallback: bool = False,
|
||||
) -> None:
|
||||
super().__init__(charset, use_unicode, str_fallback)
|
||||
self._cache_field_types: Dict[
|
||||
int,
|
||||
Callable[[bytes, DescriptionType], PythonProducedType],
|
||||
] = {}
|
||||
|
||||
@staticmethod
|
||||
def escape(value: Any, sql_mode: Optional[Union[str, bytes]] = None) -> Any:
|
||||
"""
|
||||
Escapes special characters as they are expected to by when MySQL
|
||||
receives them.
|
||||
As found in MySQL source mysys/charset.c
|
||||
|
||||
Returns the value if not a string, or the escaped string.
|
||||
"""
|
||||
if isinstance(sql_mode, bytes):
|
||||
# sql_mode is returned as bytes if use_unicode is set to False during connect()
|
||||
sql_mode = sql_mode.decode()
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
if sql_mode is not None and SQLMode.NO_BACKSLASH_ESCAPES in sql_mode:
|
||||
return value.replace(b"'", b"''")
|
||||
value = value.replace(b"\\", b"\\\\")
|
||||
value = value.replace(b"\n", b"\\n")
|
||||
value = value.replace(b"\r", b"\\r")
|
||||
value = value.replace(b"\047", b"\134\047") # single quotes
|
||||
value = value.replace(b"\042", b"\134\042") # double quotes
|
||||
value = value.replace(b"\032", b"\134\032") # for Win32
|
||||
elif isinstance(value, str) and not isinstance(value, HexLiteral):
|
||||
if sql_mode is not None and SQLMode.NO_BACKSLASH_ESCAPES in sql_mode:
|
||||
return value.replace("'", "''")
|
||||
value = value.replace("\\", "\\\\")
|
||||
value = value.replace("\n", "\\n")
|
||||
value = value.replace("\r", "\\r")
|
||||
value = value.replace("\047", "\134\047") # single quotes
|
||||
value = value.replace("\042", "\134\042") # double quotes
|
||||
value = value.replace("\032", "\134\032") # for Win32
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def quote(buf: Optional[Union[float, int, Decimal, HexLiteral, bytes]]) -> bytes:
|
||||
"""
|
||||
Quote the parameters for commands. General rules:
|
||||
o numbers are returns as bytes using ascii codec
|
||||
o None is returned as bytearray(b'NULL')
|
||||
o Everything else is single quoted '<buf>'
|
||||
|
||||
Returns a bytearray object.
|
||||
"""
|
||||
if isinstance(buf, NUMERIC_TYPES):
|
||||
return str(buf).encode("ascii")
|
||||
if isinstance(buf, type(None)):
|
||||
return bytearray(b"NULL")
|
||||
return bytearray(b"'" + buf + b"'") # type: ignore[operator]
|
||||
|
||||
def to_mysql(self, value: MySQLConvertibleType) -> MySQLProducedType:
|
||||
"""Convert Python data type to MySQL"""
|
||||
if isinstance(value, Enum):
|
||||
value = value.value
|
||||
# check if type of value object matches any one of the native supported conversion types
|
||||
# most of the types will match the condition below
|
||||
type_name: str = NATIVE_SUPPORTED_CONVERSION_TYPES.get(type(value), "")
|
||||
if not type_name:
|
||||
# check if the value object inherits from one of the native supported conversion types
|
||||
type_name = next(
|
||||
(
|
||||
name
|
||||
for data_type, name in NATIVE_SUPPORTED_CONVERSION_TYPES.items()
|
||||
if isinstance(value, data_type)
|
||||
),
|
||||
value.__class__.__name__.lower(),
|
||||
)
|
||||
try:
|
||||
converted: MySQLProducedType = getattr(self, f"_{type_name}_to_mysql")(
|
||||
value
|
||||
)
|
||||
return converted
|
||||
except AttributeError:
|
||||
# Value type is not a native one, nor a subclass of a native one
|
||||
if self.str_fallback:
|
||||
return str(value).encode()
|
||||
raise TypeError(
|
||||
f"Python '{type_name}' cannot be converted to a MySQL type"
|
||||
) from None
|
||||
|
||||
def to_python(
|
||||
self,
|
||||
vtype: DescriptionType,
|
||||
value: Optional[bytes],
|
||||
) -> PythonProducedType:
|
||||
"""Convert MySQL data type to Python"""
|
||||
# \x00
|
||||
if value == 0 and vtype[1] != FieldType.BIT:
|
||||
# Don't go further when we hit a NULL value
|
||||
return None
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if not self._cache_field_types:
|
||||
self._cache_field_types = {}
|
||||
for name, info in FieldType.desc.items():
|
||||
try:
|
||||
self._cache_field_types[info[0]] = getattr(
|
||||
self, f"_{name.lower()}_to_python"
|
||||
)
|
||||
except AttributeError:
|
||||
# We ignore field types which has no method
|
||||
pass
|
||||
|
||||
try:
|
||||
return self._cache_field_types[vtype[1]](value, vtype)
|
||||
except KeyError:
|
||||
# If one type is not defined, we just return the value as str
|
||||
try:
|
||||
return value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return value
|
||||
except ValueError as err:
|
||||
raise ValueError(f"{err} (field {vtype[0]})") from err
|
||||
except TypeError as err:
|
||||
raise TypeError(f"{err} (field {vtype[0]})") from err
|
||||
|
||||
@staticmethod
|
||||
def _int_to_mysql(value: int) -> int:
|
||||
"""Convert value to int"""
|
||||
return int(value)
|
||||
|
||||
@staticmethod
|
||||
def _long_to_mysql(value: int) -> int:
|
||||
"""Convert value to int
|
||||
|
||||
Note: There is no type "long" in Python 3 since integers are of unlimited size.
|
||||
Since Python 2 is no longer supported, this method should be deprecated.
|
||||
"""
|
||||
return int(value)
|
||||
|
||||
@staticmethod
|
||||
def _float_to_mysql(value: float) -> Optional[float]:
|
||||
"""Convert value to float"""
|
||||
if math.isnan(value):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
def _str_to_mysql(self, value: str) -> Union[bytes, HexLiteral]:
|
||||
"""Convert value to string"""
|
||||
return self._unicode_to_mysql(value)
|
||||
|
||||
def _unicode_to_mysql(self, value: str) -> Union[bytes, HexLiteral]:
|
||||
"""Convert unicode"""
|
||||
charset = self.charset
|
||||
charset_id = self.charset_id
|
||||
if charset == "binary":
|
||||
charset = "utf8"
|
||||
charset_id = self._character_set.get_charset_info(charset)[0]
|
||||
encoded = value.encode(charset)
|
||||
if charset_id in self._character_set.slash_charsets:
|
||||
if b"\x5c" in encoded:
|
||||
return HexLiteral(value, charset)
|
||||
return encoded
|
||||
|
||||
@staticmethod
|
||||
def _bytes_to_mysql(value: bytes) -> bytes:
|
||||
"""Convert value to bytes"""
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _bytearray_to_mysql(value: bytearray) -> bytes:
|
||||
"""Convert value to bytes"""
|
||||
return bytes(value)
|
||||
|
||||
@staticmethod
|
||||
def _bool_to_mysql(value: bool) -> int:
|
||||
"""Convert value to boolean"""
|
||||
return 1 if value else 0
|
||||
|
||||
@staticmethod
|
||||
def _nonetype_to_mysql(value: None) -> None: # pylint: disable=unused-argument
|
||||
"""
|
||||
This would return what None would be in MySQL, but instead we
|
||||
leave it None and return it right away. The actual conversion
|
||||
from None to NULL happens in the quoting functionality.
|
||||
|
||||
Return None.
|
||||
"""
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _datetime_to_mysql(value: datetime.datetime) -> bytes:
|
||||
"""
|
||||
Converts a datetime instance to a string suitable for MySQL.
|
||||
The returned string has format: %Y-%m-%d %H:%M:%S[.%f]
|
||||
|
||||
If the instance isn't a datetime.datetime type, it return None.
|
||||
|
||||
Returns a bytes.
|
||||
"""
|
||||
if value.microsecond:
|
||||
fmt = "{0:04d}-{1:02d}-{2:02d} {3:02d}:{4:02d}:{5:02d}.{6:06d}"
|
||||
return fmt.format(
|
||||
value.year,
|
||||
value.month,
|
||||
value.day,
|
||||
value.hour,
|
||||
value.minute,
|
||||
value.second,
|
||||
value.microsecond,
|
||||
).encode("ascii")
|
||||
|
||||
fmt = "{0:04d}-{1:02d}-{2:02d} {3:02d}:{4:02d}:{5:02d}"
|
||||
return fmt.format(
|
||||
value.year,
|
||||
value.month,
|
||||
value.day,
|
||||
value.hour,
|
||||
value.minute,
|
||||
value.second,
|
||||
).encode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def _date_to_mysql(value: datetime.date) -> bytes:
|
||||
"""
|
||||
Converts a date instance to a string suitable for MySQL.
|
||||
The returned string has format: %Y-%m-%d
|
||||
|
||||
If the instance isn't a datetime.date type, it return None.
|
||||
|
||||
Returns a bytes.
|
||||
"""
|
||||
return f"{value.year:04d}-{value.month:02d}-{value.day:02d}".encode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def _time_to_mysql(value: datetime.time) -> bytes:
|
||||
"""
|
||||
Converts a time instance to a string suitable for MySQL.
|
||||
The returned string has format: %H:%M:%S[.%f]
|
||||
|
||||
If the instance isn't a datetime.time type, it return None.
|
||||
|
||||
Returns a bytes.
|
||||
"""
|
||||
if value.microsecond:
|
||||
return value.strftime("%H:%M:%S.%f").encode("ascii")
|
||||
return value.strftime("%H:%M:%S").encode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def _struct_time_to_mysql(value: time.struct_time) -> bytes:
|
||||
"""
|
||||
Converts a time.struct_time sequence to a string suitable
|
||||
for MySQL.
|
||||
The returned string has format: %Y-%m-%d %H:%M:%S
|
||||
|
||||
Returns a bytes or None when not valid.
|
||||
"""
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", value).encode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def _timedelta_to_mysql(value: datetime.timedelta) -> bytes:
|
||||
"""
|
||||
Converts a timedelta instance to a string suitable for MySQL.
|
||||
The returned string has format: %H:%M:%S
|
||||
|
||||
Returns a bytes.
|
||||
"""
|
||||
seconds = abs(value.days * 86400 + value.seconds)
|
||||
|
||||
if value.microseconds:
|
||||
fmt = "{0:02d}:{1:02d}:{2:02d}.{3:06d}"
|
||||
if value.days < 0:
|
||||
mcs = 1000000 - value.microseconds
|
||||
seconds -= 1
|
||||
else:
|
||||
mcs = value.microseconds
|
||||
else:
|
||||
fmt = "{0:02d}:{1:02d}:{2:02d}"
|
||||
|
||||
if value.days < 0:
|
||||
fmt = "-" + fmt
|
||||
|
||||
(hours, remainder) = divmod(seconds, 3600)
|
||||
(mins, secs) = divmod(remainder, 60)
|
||||
|
||||
if value.microseconds:
|
||||
result = fmt.format(hours, mins, secs, mcs)
|
||||
else:
|
||||
result = fmt.format(hours, mins, secs)
|
||||
|
||||
return result.encode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def _decimal_to_mysql(value: Decimal) -> Optional[bytes]:
|
||||
"""
|
||||
Converts a decimal.Decimal instance to a string suitable for
|
||||
MySQL.
|
||||
|
||||
Returns a bytes or None when not valid.
|
||||
"""
|
||||
if isinstance(value, Decimal):
|
||||
return str(value).encode("ascii")
|
||||
|
||||
return None
|
||||
|
||||
def row_to_python(
|
||||
self, row: Tuple[bytes, ...], fields: List[DescriptionType]
|
||||
) -> Tuple[PythonProducedType, ...]:
|
||||
"""Convert a MySQL text result row to Python types
|
||||
|
||||
The row argument is a sequence containing text result returned
|
||||
by a MySQL server. Each value of the row is converted to the
|
||||
using the field type information in the fields argument.
|
||||
|
||||
Returns a tuple.
|
||||
"""
|
||||
i = 0
|
||||
result: List[PythonProducedType] = [None] * len(fields)
|
||||
|
||||
if not self._cache_field_types:
|
||||
self._cache_field_types = {}
|
||||
for name, info in FieldType.desc.items():
|
||||
try:
|
||||
self._cache_field_types[info[0]] = getattr(
|
||||
self, f"_{name.lower()}_to_python"
|
||||
)
|
||||
except AttributeError:
|
||||
# We ignore field types which has no method
|
||||
pass
|
||||
|
||||
for field in fields:
|
||||
field_type = field[1]
|
||||
|
||||
if (row[i] == 0 and field_type != FieldType.BIT) or row[i] is None:
|
||||
# Don't convert NULL value
|
||||
i += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
result[i] = self._cache_field_types[field_type](row[i], field)
|
||||
except KeyError:
|
||||
# If one type is not defined, we just return the value as str
|
||||
try:
|
||||
result[i] = row[i].decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
result[i] = row[i]
|
||||
except (ValueError, TypeError) as err:
|
||||
# Item "ValueError" of "Union[ValueError, TypeError]" has no attribute "message"
|
||||
err.message = f"{err} (field {field[0]})" # type: ignore[union-attr]
|
||||
raise
|
||||
|
||||
i += 1
|
||||
|
||||
return tuple(result)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@staticmethod
|
||||
def _float_to_python(value: bytes, desc: Optional[DescriptionType] = None) -> float:
|
||||
"""
|
||||
Returns value as float type.
|
||||
"""
|
||||
return float(value)
|
||||
|
||||
_double_to_python = _float_to_python
|
||||
|
||||
@staticmethod
|
||||
def _int_to_python(value: bytes, desc: Optional[DescriptionType] = None) -> int:
|
||||
"""
|
||||
Returns value as int type.
|
||||
"""
|
||||
return int(value)
|
||||
|
||||
_tiny_to_python = _int_to_python
|
||||
_short_to_python = _int_to_python
|
||||
_int24_to_python = _int_to_python
|
||||
_long_to_python = _int_to_python
|
||||
_longlong_to_python = _int_to_python
|
||||
|
||||
def _decimal_to_python(
|
||||
self, value: bytes, desc: Optional[DescriptionType] = None
|
||||
) -> Decimal:
|
||||
"""
|
||||
Returns value as a decimal.Decimal.
|
||||
"""
|
||||
val = value.decode(self.charset)
|
||||
return Decimal(val)
|
||||
|
||||
_newdecimal_to_python = _decimal_to_python
|
||||
|
||||
@staticmethod
|
||||
def _str(value: bytes, desc: Optional[DescriptionType] = None) -> str:
|
||||
"""
|
||||
Returns value as str type.
|
||||
"""
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _bit_to_python(value: bytes, dsc: Optional[DescriptionType] = None) -> int:
|
||||
"""Returns BIT columntype as integer"""
|
||||
int_val = value
|
||||
if len(int_val) < 8:
|
||||
int_val = b"\x00" * (8 - len(int_val)) + int_val
|
||||
return int(struct.unpack(">Q", int_val)[0])
|
||||
|
||||
@staticmethod
|
||||
def _date_to_python(
|
||||
value: bytes, dsc: Optional[DescriptionType] = None
|
||||
) -> Optional[datetime.date]:
|
||||
"""Converts TIME column MySQL to a python datetime.datetime type.
|
||||
|
||||
Raises ValueError if the value can not be converted.
|
||||
|
||||
Returns DATE column type as datetime.date type.
|
||||
"""
|
||||
if isinstance(value, datetime.date):
|
||||
return value
|
||||
try:
|
||||
parts = value.split(b"-")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"invalid datetime format: {parts} len: {len(parts)}")
|
||||
try:
|
||||
return datetime.date(int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
except ValueError:
|
||||
return None
|
||||
except (IndexError, ValueError):
|
||||
raise ValueError(
|
||||
f"Could not convert {repr(value)} to python datetime.timedelta"
|
||||
) from None
|
||||
|
||||
_NEWDATE_to_python = _date_to_python
|
||||
|
||||
@staticmethod
|
||||
def _time_to_python(
|
||||
value: bytes, dsc: Optional[DescriptionType] = None
|
||||
) -> datetime.timedelta:
|
||||
"""Converts TIME column value to python datetime.time value type.
|
||||
|
||||
Converts the TIME column MySQL type passed as bytes to a python
|
||||
datetime.datetime type.
|
||||
|
||||
Raises ValueError if the value can not be converted.
|
||||
|
||||
Returns datetime.timedelta type.
|
||||
"""
|
||||
mcs: Optional[Union[int, bytes]] = None
|
||||
try:
|
||||
(hms, mcs) = value.split(b".")
|
||||
mcs = int(mcs.ljust(6, b"0"))
|
||||
except (TypeError, ValueError):
|
||||
hms = value
|
||||
mcs = 0
|
||||
try:
|
||||
(hours, mins, secs) = [int(d) for d in hms.split(b":")]
|
||||
if value[0] == 45 or value[0] == "-":
|
||||
mins, secs, mcs = (
|
||||
-mins,
|
||||
-secs,
|
||||
-mcs, # pylint: disable=invalid-unary-operand-type
|
||||
)
|
||||
return datetime.timedelta(
|
||||
hours=hours, minutes=mins, seconds=secs, microseconds=mcs
|
||||
)
|
||||
except (IndexError, TypeError, ValueError):
|
||||
raise ValueError(
|
||||
CONVERT_ERROR.format(value=value, pytype="datetime.timedelta")
|
||||
) from None
|
||||
|
||||
@staticmethod
|
||||
def _datetime_to_python(
|
||||
value: bytes, dsc: Optional[DescriptionType] = None
|
||||
) -> Optional[datetime.datetime]:
|
||||
"""Converts DATETIME column value to python datetime.time value type.
|
||||
|
||||
Converts the DATETIME column MySQL type passed as bytes to a python
|
||||
datetime.datetime type.
|
||||
|
||||
Returns: datetime.datetime type.
|
||||
"""
|
||||
if isinstance(value, datetime.datetime):
|
||||
return value
|
||||
datetime_val = None
|
||||
mcs: Optional[Union[int, bytes]] = None
|
||||
try:
|
||||
(date_, time_) = value.split(b" ")
|
||||
if len(time_) > 8:
|
||||
(hms, mcs) = time_.split(b".")
|
||||
mcs = int(mcs.ljust(6, b"0"))
|
||||
else:
|
||||
hms = time_
|
||||
mcs = 0
|
||||
dtval = (
|
||||
[int(i) for i in date_.split(b"-")]
|
||||
+ [int(i) for i in hms.split(b":")]
|
||||
+ [
|
||||
mcs,
|
||||
]
|
||||
)
|
||||
if len(dtval) < 6:
|
||||
raise ValueError(f"invalid datetime format: {dtval} len: {len(dtval)}")
|
||||
# Note that by default MySQL accepts invalid timestamps
|
||||
# (this is also backward compatibility).
|
||||
# Traditionaly C/py returns None for this well formed but
|
||||
# invalid datetime for python like '0000-00-00 HH:MM:SS'.
|
||||
try:
|
||||
datetime_val = datetime.datetime(*dtval) # type: ignore[arg-type]
|
||||
except ValueError:
|
||||
return None
|
||||
except (IndexError, TypeError):
|
||||
raise ValueError(
|
||||
CONVERT_ERROR.format(value=value, pytype="datetime.timedelta")
|
||||
) from None
|
||||
|
||||
return datetime_val
|
||||
|
||||
_timestamp_to_python = _datetime_to_python
|
||||
|
||||
@staticmethod
|
||||
def _year_to_python(value: bytes, dsc: Optional[DescriptionType] = None) -> int:
|
||||
"""Returns YEAR column type as integer"""
|
||||
try:
|
||||
year = int(value)
|
||||
except ValueError as err:
|
||||
raise ValueError(f"Failed converting YEAR to int ({repr(value)})") from err
|
||||
|
||||
return year
|
||||
|
||||
def _set_to_python(
|
||||
self, value: bytes, dsc: Optional[DescriptionType] = None
|
||||
) -> Set[str]:
|
||||
"""Returns SET column type as set
|
||||
|
||||
Actually, MySQL protocol sees a SET as a string type field. So this
|
||||
code isn't called directly, but used by STRING_to_python() method.
|
||||
|
||||
Returns SET column type as a set.
|
||||
"""
|
||||
set_type = None
|
||||
val = value.decode(self.charset)
|
||||
if not val:
|
||||
return set()
|
||||
try:
|
||||
set_type = set(val.split(","))
|
||||
except ValueError as err:
|
||||
raise ValueError(
|
||||
f"Could not convert set {repr(value)} to a sequence"
|
||||
) from err
|
||||
return set_type
|
||||
|
||||
def _string_to_python(
|
||||
self, value: bytes, dsc: Optional[DescriptionType] = None
|
||||
) -> Union[StrOrBytes, Set[str]]:
|
||||
"""
|
||||
Note that a SET is a string too, but using the FieldFlag we can see
|
||||
whether we have to split it.
|
||||
|
||||
Returns string typed columns as string type.
|
||||
"""
|
||||
if self.charset == "binary":
|
||||
return value
|
||||
if dsc is not None:
|
||||
if dsc[1] == FieldType.JSON and self.use_unicode:
|
||||
return value.decode(self.charset)
|
||||
if dsc[7] & FieldFlag.SET:
|
||||
return self._set_to_python(value, dsc)
|
||||
# 'binary' charset
|
||||
if dsc[8] == 63:
|
||||
return value
|
||||
if isinstance(value, (bytes, bytearray)) and self.use_unicode:
|
||||
try:
|
||||
return value.decode(self.charset)
|
||||
except UnicodeDecodeError:
|
||||
return value
|
||||
|
||||
return value
|
||||
|
||||
_var_string_to_python = _string_to_python
|
||||
_json_to_python = _string_to_python
|
||||
|
||||
def _blob_to_python(
|
||||
self, value: bytes, dsc: Optional[DescriptionType] = None
|
||||
) -> Union[StrOrBytes, Set[str]]:
|
||||
"""Convert BLOB data type to Python."""
|
||||
if dsc is not None:
|
||||
if (
|
||||
dsc[7] & FieldFlag.BLOB
|
||||
and dsc[7] & FieldFlag.BINARY
|
||||
# 'binary' charset
|
||||
and dsc[8] == 63
|
||||
):
|
||||
return bytes(value)
|
||||
return self._string_to_python(value, dsc)
|
||||
|
||||
@staticmethod
|
||||
def _vector_to_python(
|
||||
value: Optional[bytes], desc: Optional[DescriptionType] = None
|
||||
) -> Optional[array.array]:
|
||||
"""
|
||||
Converts a MySQL VECTOR value to a Python array.array type.
|
||||
|
||||
Returns an array of floats if `value` isn't `None`, otherwise `None`.
|
||||
"""
|
||||
if value is None or isinstance(value, array.array):
|
||||
return value
|
||||
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return array.array(MYSQL_VECTOR_TYPE_CODE, value)
|
||||
|
||||
raise TypeError(f"Got unsupported type {value.__class__.__name__}")
|
||||
|
||||
_long_blob_to_python = _blob_to_python
|
||||
_medium_blob_to_python = _blob_to_python
|
||||
_tiny_blob_to_python = _blob_to_python
|
||||
# pylint: enable=unused-argument
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Custom Python types used by MySQL Connector/Python"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Type
|
||||
|
||||
|
||||
class HexLiteral(str):
|
||||
"""Class holding MySQL hex literals"""
|
||||
|
||||
charset: str = ""
|
||||
original: str = ""
|
||||
|
||||
def __new__(cls: Type[HexLiteral], str_: str, charset: str = "utf8") -> HexLiteral:
|
||||
hexed = [f"{i:02x}" for i in str_.encode(charset)]
|
||||
obj = str.__new__(cls, "".join(hexed))
|
||||
obj.charset = charset
|
||||
obj.original = str_
|
||||
return obj
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "0x" + self
|
||||
@@ -0,0 +1,92 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""
|
||||
This module implements some constructors and singletons as required by the
|
||||
DB API v2.0 (PEP-249).
|
||||
"""
|
||||
|
||||
# Python Db API v2
|
||||
# pylint: disable=invalid-name
|
||||
apilevel: str = "2.0"
|
||||
"""This attribute is a string that indicates the supported DB API level."""
|
||||
|
||||
threadsafety: int = 1
|
||||
"""This attribute is an integer that indicates the supported level of thread safety
|
||||
provided by Connector/Python."""
|
||||
|
||||
paramstyle: str = "pyformat"
|
||||
"""This attribute is a string that indicates the Connector/Python default
|
||||
parameter style."""
|
||||
|
||||
import datetime
|
||||
import time
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from . import constants
|
||||
|
||||
|
||||
class _DBAPITypeObject:
|
||||
def __init__(self, *values: int) -> None:
|
||||
self.values: Tuple[int, ...] = values
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return other in self.values
|
||||
|
||||
def __ne__(self, other: object) -> bool:
|
||||
return other not in self.values
|
||||
|
||||
|
||||
Date = datetime.date
|
||||
Time = datetime.time
|
||||
Timestamp = datetime.datetime
|
||||
|
||||
|
||||
def DateFromTicks(ticks: int) -> datetime.date:
|
||||
"""Construct an object holding a date value from the given ticks value."""
|
||||
return Date(*time.localtime(ticks)[:3])
|
||||
|
||||
|
||||
def TimeFromTicks(ticks: int) -> datetime.time:
|
||||
"""Construct an object holding a time value from the given ticks value."""
|
||||
return Time(*time.localtime(ticks)[3:6])
|
||||
|
||||
|
||||
def TimestampFromTicks(ticks: int) -> datetime.datetime:
|
||||
"""Construct an object holding a time stamp from the given ticks value."""
|
||||
return Timestamp(*time.localtime(ticks)[:6])
|
||||
|
||||
|
||||
Binary = bytes
|
||||
|
||||
STRING = _DBAPITypeObject(*constants.FieldType.get_string_types())
|
||||
BINARY = _DBAPITypeObject(*constants.FieldType.get_binary_types())
|
||||
NUMBER = _DBAPITypeObject(*constants.FieldType.get_number_types())
|
||||
DATETIME = _DBAPITypeObject(*constants.FieldType.get_timestamp_types())
|
||||
ROWID = _DBAPITypeObject()
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
@@ -0,0 +1,654 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="override"
|
||||
|
||||
"""Django database Backend using MySQL Connector/Python.
|
||||
|
||||
This Django database backend is heavily based on the MySQL backend from Django.
|
||||
|
||||
Changes include:
|
||||
* Support for microseconds (MySQL 5.6.3 and later)
|
||||
* Using INFORMATION_SCHEMA where possible
|
||||
* Using new defaults for, for example SQL_AUTO_IS_NULL
|
||||
|
||||
Requires and comes with MySQL Connector/Python v8.0.22 and later:
|
||||
http://dev.mysql.com/downloads/connector/python/
|
||||
"""
|
||||
|
||||
import warnings
|
||||
|
||||
from datetime import datetime, time
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
Generator,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import IntegrityError
|
||||
from django.db.backends.base.base import BaseDatabaseWrapper
|
||||
from django.utils import dateparse, timezone
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from mysql.connector.types import MySQLConvertibleType
|
||||
|
||||
try:
|
||||
import mysql.connector
|
||||
|
||||
from mysql.connector.conversion import MySQLConverter
|
||||
from mysql.connector.custom_types import HexLiteral
|
||||
from mysql.connector.pooling import PooledMySQLConnection
|
||||
from mysql.connector.types import ParamsSequenceOrDictType, RowType, StrOrBytes
|
||||
except ImportError as err:
|
||||
raise ImproperlyConfigured(f"Error loading mysql.connector module: {err}") from err
|
||||
|
||||
try:
|
||||
from _mysql_connector import datetime_to_mysql
|
||||
except ImportError:
|
||||
HAVE_CEXT = False
|
||||
else:
|
||||
HAVE_CEXT = True
|
||||
|
||||
from .client import DatabaseClient
|
||||
from .creation import DatabaseCreation
|
||||
from .features import DatabaseFeatures
|
||||
from .introspection import DatabaseIntrospection
|
||||
from .operations import DatabaseOperations
|
||||
from .schema import DatabaseSchemaEditor
|
||||
from .validation import DatabaseValidation
|
||||
|
||||
Error = mysql.connector.Error
|
||||
DatabaseError = mysql.connector.DatabaseError
|
||||
NotSupportedError = mysql.connector.NotSupportedError
|
||||
OperationalError = mysql.connector.OperationalError
|
||||
ProgrammingError = mysql.connector.ProgrammingError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract, MySQLCursorAbstract
|
||||
|
||||
|
||||
def adapt_datetime_with_timezone_support(value: datetime) -> StrOrBytes:
|
||||
"""Equivalent to DateTimeField.get_db_prep_value. Used only by raw SQL."""
|
||||
if settings.USE_TZ:
|
||||
if timezone.is_naive(value):
|
||||
warnings.warn(
|
||||
f"MySQL received a naive datetime ({value})"
|
||||
" while time zone support is active.",
|
||||
RuntimeWarning,
|
||||
)
|
||||
default_timezone = timezone.get_default_timezone()
|
||||
value = timezone.make_aware(value, default_timezone)
|
||||
# pylint: disable=no-member
|
||||
value = value.astimezone(timezone.utc).replace( # type: ignore[attr-defined]
|
||||
tzinfo=None
|
||||
)
|
||||
if HAVE_CEXT:
|
||||
mysql_datetime: bytes = datetime_to_mysql(value)
|
||||
return mysql_datetime
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
|
||||
|
||||
class CursorWrapper:
|
||||
"""Wrapper around MySQL Connector/Python's cursor class.
|
||||
|
||||
The cursor class is defined by the options passed to MySQL
|
||||
Connector/Python. If buffered option is True in those options,
|
||||
MySQLCursorBuffered will be used.
|
||||
"""
|
||||
|
||||
codes_for_integrityerror = (
|
||||
1048, # Column cannot be null
|
||||
1690, # BIGINT UNSIGNED value is out of range
|
||||
3819, # CHECK constraint is violated
|
||||
4025, # CHECK constraint failed
|
||||
)
|
||||
|
||||
def __init__(self, cursor: "MySQLCursorAbstract") -> None:
|
||||
self.cursor: "MySQLCursorAbstract" = cursor
|
||||
|
||||
@staticmethod
|
||||
def _adapt_execute_args_dict(
|
||||
args: Dict[str, MySQLConvertibleType],
|
||||
) -> Dict[str, MySQLConvertibleType]:
|
||||
if not args:
|
||||
return args
|
||||
new_args = dict(args)
|
||||
for key, value in args.items():
|
||||
if isinstance(value, datetime):
|
||||
new_args[key] = adapt_datetime_with_timezone_support(value)
|
||||
|
||||
return new_args
|
||||
|
||||
@staticmethod
|
||||
def _adapt_execute_args(
|
||||
args: Optional[Sequence[MySQLConvertibleType]],
|
||||
) -> Optional[Sequence[MySQLConvertibleType]]:
|
||||
if not args:
|
||||
return args
|
||||
new_args = list(args)
|
||||
for i, arg in enumerate(args):
|
||||
if isinstance(arg, datetime):
|
||||
new_args[i] = adapt_datetime_with_timezone_support(arg)
|
||||
|
||||
return tuple(new_args)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
query: str,
|
||||
args: Optional[
|
||||
Union[Sequence[MySQLConvertibleType], Dict[str, MySQLConvertibleType]]
|
||||
] = None,
|
||||
) -> Optional[Generator["MySQLCursorAbstract", None, None]]:
|
||||
"""Executes the given operation
|
||||
|
||||
This wrapper method around the execute()-method of the cursor is
|
||||
mainly needed to re-raise using different exceptions.
|
||||
"""
|
||||
new_args: Optional[ParamsSequenceOrDictType] = None
|
||||
if isinstance(args, dict):
|
||||
new_args = self._adapt_execute_args_dict(args)
|
||||
else:
|
||||
new_args = self._adapt_execute_args(args)
|
||||
try:
|
||||
return self.cursor.execute(query, new_args)
|
||||
except mysql.connector.OperationalError as exc:
|
||||
if exc.args[0] in self.codes_for_integrityerror:
|
||||
raise IntegrityError(*tuple(exc.args)) from None
|
||||
raise
|
||||
|
||||
def executemany(
|
||||
self,
|
||||
query: str,
|
||||
args: Sequence[
|
||||
Union[Sequence[MySQLConvertibleType], Dict[str, MySQLConvertibleType]]
|
||||
],
|
||||
) -> Optional[Generator["MySQLCursorAbstract", None, None]]:
|
||||
"""Executes the given operation
|
||||
|
||||
This wrapper method around the executemany()-method of the cursor is
|
||||
mainly needed to re-raise using different exceptions.
|
||||
"""
|
||||
try:
|
||||
return self.cursor.executemany(query, args)
|
||||
except mysql.connector.OperationalError as exc:
|
||||
if exc.args[0] in self.codes_for_integrityerror:
|
||||
raise IntegrityError(*tuple(exc.args)) from None
|
||||
raise
|
||||
|
||||
def __getattr__(self, attr: Any) -> Any:
|
||||
"""Return an attribute of wrapped cursor"""
|
||||
return getattr(self.cursor, attr)
|
||||
|
||||
def __iter__(self) -> Iterator[RowType]:
|
||||
"""Return an iterator over wrapped cursor"""
|
||||
return iter(self.cursor)
|
||||
|
||||
|
||||
class DatabaseWrapper(BaseDatabaseWrapper): # pylint: disable=abstract-method
|
||||
"""Represent a database connection."""
|
||||
|
||||
vendor = "mysql"
|
||||
# This dictionary maps Field objects to their associated MySQL column
|
||||
# types, as strings. Column-type strings can contain format strings; they'll
|
||||
# be interpolated against the values of Field.__dict__ before being output.
|
||||
# If a column type is set to None, it won't be included in the output.
|
||||
data_types = {
|
||||
"AutoField": "integer AUTO_INCREMENT",
|
||||
"BigAutoField": "bigint AUTO_INCREMENT",
|
||||
"BinaryField": "longblob",
|
||||
"BooleanField": "bool",
|
||||
"CharField": "varchar(%(max_length)s)",
|
||||
"DateField": "date",
|
||||
"DateTimeField": "datetime(6)",
|
||||
"DecimalField": "numeric(%(max_digits)s, %(decimal_places)s)",
|
||||
"DurationField": "bigint",
|
||||
"FileField": "varchar(%(max_length)s)",
|
||||
"FilePathField": "varchar(%(max_length)s)",
|
||||
"FloatField": "double precision",
|
||||
"IntegerField": "integer",
|
||||
"BigIntegerField": "bigint",
|
||||
"IPAddressField": "char(15)",
|
||||
"GenericIPAddressField": "char(39)",
|
||||
"JSONField": "json",
|
||||
"NullBooleanField": "bool",
|
||||
"OneToOneField": "integer",
|
||||
"PositiveBigIntegerField": "bigint UNSIGNED",
|
||||
"PositiveIntegerField": "integer UNSIGNED",
|
||||
"PositiveSmallIntegerField": "smallint UNSIGNED",
|
||||
"SlugField": "varchar(%(max_length)s)",
|
||||
"SmallAutoField": "smallint AUTO_INCREMENT",
|
||||
"SmallIntegerField": "smallint",
|
||||
"TextField": "longtext",
|
||||
"TimeField": "time(6)",
|
||||
"UUIDField": "char(32)",
|
||||
}
|
||||
|
||||
# For these data types:
|
||||
# - MySQL < 8.0.13 doesn't accept default values and
|
||||
# implicitly treat them as nullable
|
||||
# - all versions of MySQL doesn't support full width database
|
||||
# indexes
|
||||
_limited_data_types = (
|
||||
"tinyblob",
|
||||
"blob",
|
||||
"mediumblob",
|
||||
"longblob",
|
||||
"tinytext",
|
||||
"text",
|
||||
"mediumtext",
|
||||
"longtext",
|
||||
"json",
|
||||
)
|
||||
|
||||
operators = {
|
||||
"exact": "= %s",
|
||||
"iexact": "LIKE %s",
|
||||
"contains": "LIKE CAST(%s AS BINARY)",
|
||||
"icontains": "LIKE %s",
|
||||
"gt": "> %s",
|
||||
"gte": ">= %s",
|
||||
"lt": "< %s",
|
||||
"lte": "<= %s",
|
||||
"startswith": "LIKE CAST(%s AS BINARY)",
|
||||
"endswith": "LIKE CAST(%s AS BINARY)",
|
||||
"istartswith": "LIKE %s",
|
||||
"iendswith": "LIKE %s",
|
||||
}
|
||||
|
||||
# The patterns below are used to generate SQL pattern lookup clauses when
|
||||
# the right-hand side of the lookup isn't a raw string (it might be an expression
|
||||
# or the result of a bilateral transformation).
|
||||
# In those cases, special characters for LIKE operators (e.g. \, *, _) should be
|
||||
# escaped on database side.
|
||||
#
|
||||
# Note: we use str.format() here for readability as '%' is used as a wildcard for
|
||||
# the LIKE operator.
|
||||
pattern_esc = r"REPLACE(REPLACE(REPLACE({}, '\\', '\\\\'), '%%', '\%%'), '_', '\_')"
|
||||
pattern_ops = {
|
||||
"contains": "LIKE CAST(CONCAT('%%', {}, '%%') AS BINARY)",
|
||||
"icontains": "LIKE CONCAT('%%', {}, '%%')",
|
||||
"startswith": "LIKE CAST(CONCAT({}, '%%') AS BINARY)",
|
||||
"istartswith": "LIKE CONCAT({}, '%%')",
|
||||
"endswith": "LIKE CAST(CONCAT('%%', {}) AS BINARY)",
|
||||
"iendswith": "LIKE CONCAT('%%', {})",
|
||||
}
|
||||
|
||||
isolation_level: Optional[str] = None
|
||||
isolation_levels = {
|
||||
"read uncommitted",
|
||||
"read committed",
|
||||
"repeatable read",
|
||||
"serializable",
|
||||
}
|
||||
|
||||
Database = mysql.connector
|
||||
SchemaEditorClass = DatabaseSchemaEditor
|
||||
# Classes instantiated in __init__().
|
||||
client_class = DatabaseClient
|
||||
creation_class = DatabaseCreation
|
||||
features_class = DatabaseFeatures
|
||||
introspection_class = DatabaseIntrospection
|
||||
ops_class = DatabaseOperations
|
||||
validation_class = DatabaseValidation
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
options = self.settings_dict.get("OPTIONS")
|
||||
if options:
|
||||
self._use_pure = options.get("use_pure", not HAVE_CEXT)
|
||||
converter_class = options.get(
|
||||
"converter_class",
|
||||
DjangoMySQLConverter,
|
||||
)
|
||||
if not issubclass(converter_class, DjangoMySQLConverter):
|
||||
raise ProgrammingError(
|
||||
"Converter class should be a subclass of "
|
||||
"mysql.connector.django.base.DjangoMySQLConverter"
|
||||
)
|
||||
self.converter = converter_class()
|
||||
else:
|
||||
self.converter = DjangoMySQLConverter()
|
||||
self._use_pure = not HAVE_CEXT
|
||||
|
||||
def __getattr__(self, attr: str) -> bool:
|
||||
if attr.startswith("mysql_is"):
|
||||
return False
|
||||
raise AttributeError
|
||||
|
||||
def get_connection_params(self) -> Dict[str, Any]:
|
||||
kwargs: dict = {
|
||||
"consume_results": True,
|
||||
}
|
||||
|
||||
settings_dict = self.settings_dict
|
||||
|
||||
if settings_dict["USER"]:
|
||||
kwargs["user"] = settings_dict["USER"]
|
||||
if settings_dict["NAME"]:
|
||||
kwargs["database"] = settings_dict["NAME"]
|
||||
if settings_dict["PASSWORD"]:
|
||||
kwargs["passwd"] = settings_dict["PASSWORD"]
|
||||
if settings_dict["HOST"].startswith("/"):
|
||||
kwargs["unix_socket"] = settings_dict["HOST"]
|
||||
elif settings_dict["HOST"]:
|
||||
kwargs["host"] = settings_dict["HOST"]
|
||||
if settings_dict["PORT"]:
|
||||
kwargs["port"] = int(settings_dict["PORT"])
|
||||
if settings_dict.get("OPTIONS", {}).get("init_command"):
|
||||
kwargs["init_command"] = settings_dict["OPTIONS"]["init_command"]
|
||||
|
||||
# Raise exceptions for database warnings if DEBUG is on
|
||||
kwargs["raise_on_warnings"] = settings.DEBUG
|
||||
|
||||
kwargs["client_flags"] = [
|
||||
# Need potentially affected rows on UPDATE
|
||||
mysql.connector.constants.ClientFlag.FOUND_ROWS,
|
||||
]
|
||||
|
||||
try:
|
||||
options = settings_dict["OPTIONS"].copy()
|
||||
isolation_level = options.pop("isolation_level", None)
|
||||
if isolation_level:
|
||||
isolation_level = isolation_level.lower()
|
||||
if isolation_level not in self.isolation_levels:
|
||||
valid_levels = ", ".join(
|
||||
f"'{level}'" for level in sorted(self.isolation_levels)
|
||||
)
|
||||
raise ImproperlyConfigured(
|
||||
f"Invalid transaction isolation level '{isolation_level}' "
|
||||
f"specified.\nUse one of {valid_levels}, or None."
|
||||
)
|
||||
self.isolation_level = isolation_level
|
||||
kwargs.update(options)
|
||||
except KeyError:
|
||||
# OPTIONS missing is OK
|
||||
pass
|
||||
return kwargs
|
||||
|
||||
def get_new_connection(
|
||||
self, conn_params: Dict[str, Any]
|
||||
) -> Union[PooledMySQLConnection, "MySQLConnectionAbstract"]:
|
||||
if "converter_class" not in conn_params:
|
||||
conn_params["converter_class"] = DjangoMySQLConverter
|
||||
cnx = mysql.connector.connect(**conn_params)
|
||||
|
||||
return cnx
|
||||
|
||||
def init_connection_state(self) -> None:
|
||||
assignments = []
|
||||
if self.features.is_sql_auto_is_null_enabled: # type: ignore[attr-defined]
|
||||
# SQL_AUTO_IS_NULL controls whether an AUTO_INCREMENT column on
|
||||
# a recently inserted row will return when the field is tested
|
||||
# for NULL. Disabling this brings this aspect of MySQL in line
|
||||
# with SQL standards.
|
||||
assignments.append("SET SQL_AUTO_IS_NULL = 0")
|
||||
|
||||
if self.isolation_level:
|
||||
assignments.append(
|
||||
"SET SESSION TRANSACTION ISOLATION LEVEL "
|
||||
f"{self.isolation_level.upper()}"
|
||||
)
|
||||
|
||||
if assignments:
|
||||
with self.cursor() as cursor:
|
||||
cursor.execute("; ".join(assignments))
|
||||
|
||||
if "AUTOCOMMIT" in self.settings_dict:
|
||||
try:
|
||||
self.set_autocommit(self.settings_dict["AUTOCOMMIT"])
|
||||
except AttributeError:
|
||||
self._set_autocommit(self.settings_dict["AUTOCOMMIT"])
|
||||
|
||||
def create_cursor(self, name: Any = None) -> CursorWrapper:
|
||||
cursor = self.connection.cursor()
|
||||
return CursorWrapper(cursor)
|
||||
|
||||
def _rollback(self) -> None:
|
||||
try:
|
||||
BaseDatabaseWrapper._rollback(self) # type: ignore[attr-defined]
|
||||
except NotSupportedError:
|
||||
pass
|
||||
|
||||
def _set_autocommit(self, autocommit: bool) -> None:
|
||||
with self.wrap_database_errors:
|
||||
self.connection.autocommit = autocommit
|
||||
|
||||
def disable_constraint_checking(self) -> bool:
|
||||
"""
|
||||
Disable foreign key checks, primarily for use in adding rows with
|
||||
forward references. Always return True to indicate constraint checks
|
||||
need to be re-enabled.
|
||||
"""
|
||||
with self.cursor() as cursor:
|
||||
cursor.execute("SET foreign_key_checks=0")
|
||||
return True
|
||||
|
||||
def enable_constraint_checking(self) -> None:
|
||||
"""
|
||||
Re-enable foreign key checks after they have been disabled.
|
||||
"""
|
||||
# Override needs_rollback in case constraint_checks_disabled is
|
||||
# nested inside transaction.atomic.
|
||||
self.needs_rollback, needs_rollback = False, self.needs_rollback
|
||||
try:
|
||||
with self.cursor() as cursor:
|
||||
cursor.execute("SET foreign_key_checks=1")
|
||||
finally:
|
||||
self.needs_rollback = needs_rollback
|
||||
|
||||
def check_constraints(self, table_names: Optional[List[str]] = None) -> None:
|
||||
"""
|
||||
Check each table name in `table_names` for rows with invalid foreign
|
||||
key references. This method is intended to be used in conjunction with
|
||||
`disable_constraint_checking()` and `enable_constraint_checking()`, to
|
||||
determine if rows with invalid references were entered while constraint
|
||||
checks were off.
|
||||
"""
|
||||
with self.cursor() as cursor:
|
||||
if table_names is None:
|
||||
table_names = self.introspection.table_names(cursor)
|
||||
for table_name in table_names:
|
||||
primary_key_column_name = self.introspection.get_primary_key_column(
|
||||
cursor, table_name
|
||||
)
|
||||
if not primary_key_column_name:
|
||||
continue
|
||||
key_columns = self.introspection.get_key_columns( # type: ignore[attr-defined]
|
||||
cursor, table_name
|
||||
)
|
||||
for (
|
||||
column_name,
|
||||
referenced_table_name,
|
||||
referenced_column_name,
|
||||
) in key_columns:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT REFERRING.`{primary_key_column_name}`,
|
||||
REFERRING.`{column_name}`
|
||||
FROM `{table_name}` as REFERRING
|
||||
LEFT JOIN `{referenced_table_name}` as REFERRED
|
||||
ON (
|
||||
REFERRING.`{column_name}` =
|
||||
REFERRED.`{referenced_column_name}`
|
||||
)
|
||||
WHERE REFERRING.`{column_name}` IS NOT NULL
|
||||
AND REFERRED.`{referenced_column_name}` IS NULL
|
||||
"""
|
||||
)
|
||||
for bad_row in cursor.fetchall():
|
||||
raise IntegrityError(
|
||||
f"The row in table '{table_name}' with primary "
|
||||
f"key '{bad_row[0]}' has an invalid foreign key: "
|
||||
f"{table_name}.{column_name} contains a value "
|
||||
f"'{bad_row[1]}' that does not have a "
|
||||
f"corresponding value in "
|
||||
f"{referenced_table_name}."
|
||||
f"{referenced_column_name}."
|
||||
)
|
||||
|
||||
def is_usable(self) -> bool:
|
||||
try:
|
||||
self.connection.ping()
|
||||
except Error:
|
||||
return False
|
||||
return True
|
||||
|
||||
@cached_property # type: ignore[misc]
|
||||
@staticmethod
|
||||
def display_name() -> str:
|
||||
"""Display name."""
|
||||
return "MySQL"
|
||||
|
||||
@cached_property
|
||||
def data_type_check_constraints(self) -> Dict[str, str]:
|
||||
"""Mapping of Field objects to their SQL for CHECK constraints."""
|
||||
if self.features.supports_column_check_constraints:
|
||||
check_constraints = {
|
||||
"PositiveBigIntegerField": "`%(column)s` >= 0",
|
||||
"PositiveIntegerField": "`%(column)s` >= 0",
|
||||
"PositiveSmallIntegerField": "`%(column)s` >= 0",
|
||||
}
|
||||
return check_constraints
|
||||
return {}
|
||||
|
||||
@cached_property
|
||||
def mysql_server_data(self) -> Dict[str, Any]:
|
||||
"""Return MySQL server data."""
|
||||
with self.temporary_connection() as cursor:
|
||||
# Select some server variables and test if the time zone
|
||||
# definitions are installed. CONVERT_TZ returns NULL if 'UTC'
|
||||
# timezone isn't loaded into the mysql.time_zone table.
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT VERSION(),
|
||||
@@sql_mode,
|
||||
@@default_storage_engine,
|
||||
@@sql_auto_is_null,
|
||||
@@lower_case_table_names,
|
||||
CONVERT_TZ('2001-01-01 01:00:00', 'UTC', 'UTC') IS NOT NULL
|
||||
"""
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return {
|
||||
"version": row[0],
|
||||
"sql_mode": row[1],
|
||||
"default_storage_engine": row[2],
|
||||
"sql_auto_is_null": bool(row[3]),
|
||||
"lower_case_table_names": bool(row[4]),
|
||||
"has_zoneinfo_database": bool(row[5]),
|
||||
}
|
||||
|
||||
@cached_property
|
||||
def mysql_server_info(self) -> Any:
|
||||
"""Return MySQL version."""
|
||||
if self.connection:
|
||||
return self.connection.server_info
|
||||
with self.temporary_connection() as cursor:
|
||||
cursor.execute("SELECT VERSION()")
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
@cached_property
|
||||
def mysql_version(self) -> Tuple[int, ...]:
|
||||
"""Return MySQL version."""
|
||||
if self.connection:
|
||||
return self.connection.server_version
|
||||
config = self.get_connection_params()
|
||||
with mysql.connector.connect(**config) as conn:
|
||||
server_version: Tuple[int, ...] = conn.server_version
|
||||
return server_version
|
||||
|
||||
@cached_property
|
||||
def sql_mode(self) -> Set[str]:
|
||||
"""Return SQL mode."""
|
||||
if self.connection:
|
||||
return set(self.connection.sql_mode.split(","))
|
||||
with self.cursor() as cursor:
|
||||
cursor.execute("SELECT @@sql_mode")
|
||||
sql_mode = cursor.fetchone()
|
||||
return set(sql_mode[0].split(",") if sql_mode else ())
|
||||
|
||||
@property
|
||||
def use_pure(self) -> bool:
|
||||
"""Return True if pure Python version is being used."""
|
||||
ans: bool = self._use_pure
|
||||
return ans
|
||||
|
||||
|
||||
class DjangoMySQLConverter(MySQLConverter):
|
||||
"""Custom converter for Django."""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
@staticmethod
|
||||
def _time_to_python(value: bytes, dsc: Any = None) -> Optional[time]:
|
||||
"""Return MySQL TIME data type as datetime.time()
|
||||
|
||||
Returns datetime.time()
|
||||
"""
|
||||
return dateparse.parse_time(value.decode("utf-8"))
|
||||
|
||||
@staticmethod
|
||||
def _datetime_to_python(value: bytes, dsc: Any = None) -> Optional[datetime]:
|
||||
"""Connector/Python always returns naive datetime.datetime
|
||||
|
||||
Connector/Python always returns naive timestamps since MySQL has
|
||||
no time zone support.
|
||||
|
||||
- A naive datetime is a datetime that doesn't know its own timezone.
|
||||
|
||||
Django needs a non-naive datetime, but in this method we don't need
|
||||
to make a datetime value time zone aware since Django itself at some
|
||||
point will make it aware (at least in versions 3.2.16 and 4.1.2) when
|
||||
USE_TZ=True. This may change in a future release, we need to keep an
|
||||
eye on this behaviour.
|
||||
|
||||
Returns datetime.datetime()
|
||||
"""
|
||||
return MySQLConverter._datetime_to_python(value) if value else None
|
||||
|
||||
# pylint: enable=unused-argument
|
||||
|
||||
def _safestring_to_mysql(self, value: str) -> Union[bytes, HexLiteral]:
|
||||
return self._str_to_mysql(value)
|
||||
|
||||
def _safetext_to_mysql(self, value: str) -> Union[bytes, HexLiteral]:
|
||||
return self._str_to_mysql(value)
|
||||
|
||||
def _safebytes_to_mysql(self, value: bytes) -> bytes:
|
||||
return self._bytes_to_mysql(value)
|
||||
@@ -0,0 +1,106 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Database Client."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from django.db.backends.base.client import BaseDatabaseClient
|
||||
|
||||
|
||||
class DatabaseClient(BaseDatabaseClient):
|
||||
"""Encapsulate backend-specific methods for opening a client shell."""
|
||||
|
||||
executable_name = "mysql"
|
||||
|
||||
@classmethod
|
||||
def settings_to_cmd_args_env(
|
||||
cls, settings_dict: Dict[str, Any], parameters: Optional[Iterable[str]] = None
|
||||
) -> Tuple[List[str], Optional[Dict[str, Any]]]:
|
||||
args = [cls.executable_name]
|
||||
|
||||
db = settings_dict["OPTIONS"].get("database", settings_dict["NAME"])
|
||||
user = settings_dict["OPTIONS"].get("user", settings_dict["USER"])
|
||||
passwd = settings_dict["OPTIONS"].get("password", settings_dict["PASSWORD"])
|
||||
host = settings_dict["OPTIONS"].get("host", settings_dict["HOST"])
|
||||
port = settings_dict["OPTIONS"].get("port", settings_dict["PORT"])
|
||||
ssl_ca = settings_dict["OPTIONS"].get("ssl_ca")
|
||||
ssl_cert = settings_dict["OPTIONS"].get("ssl_cert")
|
||||
ssl_key = settings_dict["OPTIONS"].get("ssl_key")
|
||||
defaults_file = settings_dict["OPTIONS"].get("read_default_file")
|
||||
charset = settings_dict["OPTIONS"].get("charset")
|
||||
|
||||
# --defaults-file should always be the first option
|
||||
if defaults_file:
|
||||
args.append(f"--defaults-file={defaults_file}")
|
||||
|
||||
# Load any custom init_commands. We always force SQL_MODE to TRADITIONAL
|
||||
init_command = settings_dict["OPTIONS"].get("init_command", "")
|
||||
args.append(f"--init-command=SET @@session.SQL_MODE=TRADITIONAL;{init_command}")
|
||||
|
||||
if user:
|
||||
args.append(f"--user={user}")
|
||||
if passwd:
|
||||
args.append(f"--password={passwd}")
|
||||
|
||||
if host:
|
||||
if "/" in host:
|
||||
args.append(f"--socket={host}")
|
||||
else:
|
||||
args.append(f"--host={host}")
|
||||
|
||||
if port:
|
||||
args.append(f"--port={port}")
|
||||
|
||||
if db:
|
||||
args.append(f"--database={db}")
|
||||
|
||||
if ssl_ca:
|
||||
args.append(f"--ssl-ca={ssl_ca}")
|
||||
if ssl_cert:
|
||||
args.append(f"--ssl-cert={ssl_cert}")
|
||||
if ssl_key:
|
||||
args.append(f"--ssl-key={ssl_key}")
|
||||
|
||||
if charset:
|
||||
args.append(f"--default-character-set={charset}")
|
||||
|
||||
if parameters:
|
||||
args.extend(parameters)
|
||||
|
||||
return args, None
|
||||
|
||||
def runshell(self, parameters: Optional[Iterable[str]] = None) -> None:
|
||||
args, env = self.settings_to_cmd_args_env(
|
||||
self.connection.settings_dict, parameters
|
||||
)
|
||||
env = {**os.environ, **env} if env else None
|
||||
subprocess.run(args, env=env, check=True)
|
||||
@@ -0,0 +1,45 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""SQL Compiler classes."""
|
||||
|
||||
from django.db.backends.mysql.compiler import (
|
||||
SQLAggregateCompiler,
|
||||
SQLCompiler,
|
||||
SQLDeleteCompiler,
|
||||
SQLInsertCompiler,
|
||||
SQLUpdateCompiler,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SQLAggregateCompiler",
|
||||
"SQLCompiler",
|
||||
"SQLDeleteCompiler",
|
||||
"SQLInsertCompiler",
|
||||
"SQLUpdateCompiler",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Backend specific database creation."""
|
||||
|
||||
from django.db.backends.mysql.creation import DatabaseCreation
|
||||
|
||||
__all__ = ["DatabaseCreation"]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Database Features."""
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from django.db.backends.mysql.features import DatabaseFeatures as MySQLDatabaseFeatures
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
|
||||
class DatabaseFeatures(MySQLDatabaseFeatures):
|
||||
"""Database Features Specification class."""
|
||||
|
||||
empty_fetchmany_value: List[Any] = []
|
||||
|
||||
@cached_property
|
||||
def can_introspect_check_constraints(self) -> bool: # type: ignore[override]
|
||||
"""Check if backend support introspection CHECK of constraints."""
|
||||
return self.connection.mysql_version >= (8, 0, 16)
|
||||
|
||||
@cached_property
|
||||
def supports_microsecond_precision(self) -> bool:
|
||||
"""Check if backend support microsecond precision."""
|
||||
return self.connection.mysql_version >= (5, 6, 3)
|
||||
@@ -0,0 +1,461 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="override,attr-defined,call-arg"
|
||||
|
||||
"""Database Introspection."""
|
||||
|
||||
from collections import namedtuple
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import sqlparse
|
||||
|
||||
from django import VERSION as DJANGO_VERSION
|
||||
from django.db.backends.base.introspection import (
|
||||
BaseDatabaseIntrospection,
|
||||
FieldInfo as BaseFieldInfo,
|
||||
TableInfo,
|
||||
)
|
||||
from django.db.models import Index
|
||||
from django.utils.datastructures import OrderedSet
|
||||
|
||||
from mysql.connector.constants import FieldType
|
||||
|
||||
# from .base import CursorWrapper produces a circular import error,
|
||||
# avoiding importing CursorWrapper explicitly, using a documented
|
||||
# trick; write the imports inside if TYPE_CHECKING: so that they
|
||||
# are not executed at runtime.
|
||||
# Ref: https://buildmedia.readthedocs.org/media/pdf/mypy/stable/mypy.pdf [page 42]
|
||||
if TYPE_CHECKING:
|
||||
# CursorWraper is used exclusively for type hinting
|
||||
from mysql.connector.django.base import CursorWrapper
|
||||
|
||||
# Based on my investigation, named tuples to
|
||||
# comply with mypy need to define a static list or tuple
|
||||
# for field_names (second argument). In this case, the field
|
||||
# names are created dynamically for FieldInfo which triggers
|
||||
# a mypy error. The solution is not straightforward since
|
||||
# FieldInfo attributes are Django version dependent. Code
|
||||
# refactory is needed to fix this issue.
|
||||
FieldInfo = namedtuple( # type: ignore[misc]
|
||||
"FieldInfo",
|
||||
BaseFieldInfo._fields + ("extra", "is_unsigned", "has_json_constraint"),
|
||||
)
|
||||
if DJANGO_VERSION < (3, 2, 0):
|
||||
InfoLine = namedtuple(
|
||||
"InfoLine",
|
||||
"col_name data_type max_len num_prec num_scale extra column_default "
|
||||
"is_unsigned",
|
||||
)
|
||||
else:
|
||||
InfoLine = namedtuple( # type: ignore[no-redef]
|
||||
"InfoLine",
|
||||
"col_name data_type max_len num_prec num_scale extra column_default "
|
||||
"collation is_unsigned",
|
||||
)
|
||||
|
||||
|
||||
class DatabaseIntrospection(BaseDatabaseIntrospection):
|
||||
"""Encapsulate backend-specific introspection utilities."""
|
||||
|
||||
data_types_reverse = {
|
||||
FieldType.BLOB: "TextField",
|
||||
FieldType.DECIMAL: "DecimalField",
|
||||
FieldType.NEWDECIMAL: "DecimalField",
|
||||
FieldType.DATE: "DateField",
|
||||
FieldType.DATETIME: "DateTimeField",
|
||||
FieldType.DOUBLE: "FloatField",
|
||||
FieldType.FLOAT: "FloatField",
|
||||
FieldType.INT24: "IntegerField",
|
||||
FieldType.LONG: "IntegerField",
|
||||
FieldType.LONGLONG: "BigIntegerField",
|
||||
FieldType.SHORT: "SmallIntegerField",
|
||||
FieldType.STRING: "CharField",
|
||||
FieldType.TIME: "TimeField",
|
||||
FieldType.TIMESTAMP: "DateTimeField",
|
||||
FieldType.TINY: "IntegerField",
|
||||
FieldType.TINY_BLOB: "TextField",
|
||||
FieldType.MEDIUM_BLOB: "TextField",
|
||||
FieldType.LONG_BLOB: "TextField",
|
||||
FieldType.VAR_STRING: "CharField",
|
||||
}
|
||||
|
||||
def get_field_type(self, data_type: str, description: FieldInfo) -> str:
|
||||
field_type = super().get_field_type(data_type, description) # type: ignore[arg-type]
|
||||
if "auto_increment" in description.extra:
|
||||
if field_type == "IntegerField":
|
||||
return "AutoField"
|
||||
if field_type == "BigIntegerField":
|
||||
return "BigAutoField"
|
||||
if field_type == "SmallIntegerField":
|
||||
return "SmallAutoField"
|
||||
if description.is_unsigned:
|
||||
if field_type == "BigIntegerField":
|
||||
return "PositiveBigIntegerField"
|
||||
if field_type == "IntegerField":
|
||||
return "PositiveIntegerField"
|
||||
if field_type == "SmallIntegerField":
|
||||
return "PositiveSmallIntegerField"
|
||||
# JSON data type is an alias for LONGTEXT in MariaDB, use check
|
||||
# constraints clauses to introspect JSONField.
|
||||
if description.has_json_constraint:
|
||||
return "JSONField"
|
||||
return field_type
|
||||
|
||||
def get_table_list(self, cursor: "CursorWrapper") -> List[TableInfo]:
|
||||
"""Return a list of table and view names in the current database."""
|
||||
cursor.execute("SHOW FULL TABLES")
|
||||
return [
|
||||
TableInfo(row[0], {"BASE TABLE": "t", "VIEW": "v"}.get(row[1]))
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
def get_table_description(
|
||||
self, cursor: "CursorWrapper", table_name: str
|
||||
) -> List[FieldInfo]:
|
||||
"""
|
||||
Return a description of the table with the DB-API cursor.description
|
||||
interface."
|
||||
"""
|
||||
json_constraints: Dict[Any, Any] = {}
|
||||
# A default collation for the given table.
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT table_collation
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = %s
|
||||
""",
|
||||
[table_name],
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
default_column_collation = row[0] if row else ""
|
||||
# information_schema database gives more accurate results for some figures:
|
||||
# - varchar length returned by cursor.description is an internal length,
|
||||
# not visible length (#5725)
|
||||
# - precision and scale (for decimal fields) (#5014)
|
||||
# - auto_increment is not available in cursor.description
|
||||
if DJANGO_VERSION < (3, 2, 0):
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
column_name, data_type, character_maximum_length,
|
||||
numeric_precision, numeric_scale, extra, column_default,
|
||||
CASE
|
||||
WHEN column_type LIKE '%% unsigned' THEN 1
|
||||
ELSE 0
|
||||
END AS is_unsigned
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = %s AND table_schema = DATABASE()
|
||||
""",
|
||||
[table_name],
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
column_name, data_type, character_maximum_length,
|
||||
numeric_precision, numeric_scale, extra, column_default,
|
||||
CASE
|
||||
WHEN collation_name = %s THEN NULL
|
||||
ELSE collation_name
|
||||
END AS collation_name,
|
||||
CASE
|
||||
WHEN column_type LIKE '%% unsigned' THEN 1
|
||||
ELSE 0
|
||||
END AS is_unsigned
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = %s AND table_schema = DATABASE()
|
||||
""",
|
||||
[default_column_collation, table_name],
|
||||
)
|
||||
field_info = {line[0]: InfoLine(*line) for line in cursor.fetchall()}
|
||||
|
||||
cursor.execute(
|
||||
f"SELECT * FROM {self.connection.ops.quote_name(table_name)} LIMIT 1"
|
||||
)
|
||||
|
||||
def to_int(i: Any) -> Optional[int]:
|
||||
return int(i) if i is not None else i
|
||||
|
||||
fields = []
|
||||
for line in cursor.description:
|
||||
info = field_info[line[0]]
|
||||
if DJANGO_VERSION < (3, 2, 0):
|
||||
fields.append(
|
||||
FieldInfo(
|
||||
*line[:3],
|
||||
to_int(info.max_len) or line[3],
|
||||
to_int(info.num_prec) or line[4],
|
||||
to_int(info.num_scale) or line[5],
|
||||
line[6],
|
||||
info.column_default,
|
||||
info.extra,
|
||||
info.is_unsigned,
|
||||
line[0] in json_constraints,
|
||||
)
|
||||
)
|
||||
else:
|
||||
fields.append(
|
||||
FieldInfo(
|
||||
*line[:3],
|
||||
to_int(info.max_len) or line[3],
|
||||
to_int(info.num_prec) or line[4],
|
||||
to_int(info.num_scale) or line[5],
|
||||
line[6],
|
||||
info.column_default,
|
||||
info.collation,
|
||||
info.extra,
|
||||
info.is_unsigned,
|
||||
line[0] in json_constraints,
|
||||
)
|
||||
)
|
||||
return fields
|
||||
|
||||
def get_indexes(
|
||||
self, cursor: "CursorWrapper", table_name: str
|
||||
) -> Dict[int, Dict[str, bool]]:
|
||||
"""Return indexes from table."""
|
||||
cursor.execute(f"SHOW INDEX FROM {self.connection.ops.quote_name(table_name)}")
|
||||
# Do a two-pass search for indexes: on first pass check which indexes
|
||||
# are multicolumn, on second pass check which single-column indexes
|
||||
# are present.
|
||||
rows = list(cursor.fetchall())
|
||||
multicol_indexes = set()
|
||||
for row in rows:
|
||||
if row[3] > 1:
|
||||
multicol_indexes.add(row[2])
|
||||
indexes: Dict[int, Dict[str, bool]] = {}
|
||||
for row in rows:
|
||||
if row[2] in multicol_indexes:
|
||||
continue
|
||||
if row[4] not in indexes:
|
||||
indexes[row[4]] = {"primary_key": False, "unique": False}
|
||||
# It's possible to have the unique and PK constraints in
|
||||
# separate indexes.
|
||||
if row[2] == "PRIMARY":
|
||||
indexes[row[4]]["primary_key"] = True
|
||||
if not row[1]:
|
||||
indexes[row[4]]["unique"] = True
|
||||
return indexes
|
||||
|
||||
def get_primary_key_column(
|
||||
self, cursor: "CursorWrapper", table_name: str
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Returns the name of the primary key column for the given table
|
||||
"""
|
||||
for column in self.get_indexes(cursor, table_name).items():
|
||||
if column[1]["primary_key"]:
|
||||
return column[0]
|
||||
return None
|
||||
|
||||
def get_sequences(
|
||||
self, cursor: "CursorWrapper", table_name: str, table_fields: Any = ()
|
||||
) -> List[Dict[str, str]]:
|
||||
for field_info in self.get_table_description(cursor, table_name):
|
||||
if "auto_increment" in field_info.extra:
|
||||
# MySQL allows only one auto-increment column per table.
|
||||
return [{"table": table_name, "column": field_info.name}]
|
||||
return []
|
||||
|
||||
def get_relations(
|
||||
self, cursor: "CursorWrapper", table_name: str
|
||||
) -> Dict[str, Tuple[str, str]]:
|
||||
"""
|
||||
Return a dictionary of {field_name: (field_name_other_table, other_table)}
|
||||
representing all relationships to the given table.
|
||||
"""
|
||||
constraints = self.get_key_columns(cursor, table_name)
|
||||
relations = {}
|
||||
for my_fieldname, other_table, other_field in constraints:
|
||||
relations[my_fieldname] = (other_field, other_table)
|
||||
return relations
|
||||
|
||||
def get_key_columns(
|
||||
self, cursor: "CursorWrapper", table_name: str
|
||||
) -> List[Tuple[str, str, str]]:
|
||||
"""
|
||||
Return a list of (column_name, referenced_table_name, referenced_column_name)
|
||||
for all key columns in the given table.
|
||||
"""
|
||||
key_columns: List[Any] = []
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT column_name, referenced_table_name, referenced_column_name
|
||||
FROM information_schema.key_column_usage
|
||||
WHERE table_name = %s
|
||||
AND table_schema = DATABASE()
|
||||
AND referenced_table_name IS NOT NULL
|
||||
AND referenced_column_name IS NOT NULL""",
|
||||
[table_name],
|
||||
)
|
||||
key_columns.extend(cursor.fetchall())
|
||||
return key_columns
|
||||
|
||||
def get_storage_engine(self, cursor: "CursorWrapper", table_name: str) -> str:
|
||||
"""
|
||||
Retrieve the storage engine for a given table. Return the default
|
||||
storage engine if the table doesn't exist.
|
||||
"""
|
||||
cursor.execute(
|
||||
"SELECT engine FROM information_schema.tables WHERE table_name = %s",
|
||||
[table_name],
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
# pylint: disable=protected-access
|
||||
if not result:
|
||||
return self.connection.features._mysql_storage_engine
|
||||
# pylint: enable=protected-access
|
||||
return result[0]
|
||||
|
||||
def _parse_constraint_columns(
|
||||
self, check_clause: Any, columns: Set[str]
|
||||
) -> OrderedSet:
|
||||
check_columns: OrderedSet = OrderedSet()
|
||||
statement = sqlparse.parse(check_clause)[0]
|
||||
tokens = (token for token in statement.flatten() if not token.is_whitespace)
|
||||
for token in tokens:
|
||||
if (
|
||||
token.ttype == sqlparse.tokens.Name
|
||||
and self.connection.ops.quote_name(token.value) == token.value
|
||||
and token.value[1:-1] in columns
|
||||
):
|
||||
check_columns.add(token.value[1:-1])
|
||||
return check_columns
|
||||
|
||||
def get_constraints(
|
||||
self, cursor: "CursorWrapper", table_name: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve any constraints or keys (unique, pk, fk, check, index) across
|
||||
one or more columns.
|
||||
"""
|
||||
constraints: Dict[str, Any] = {}
|
||||
# Get the actual constraint names and columns
|
||||
name_query = """
|
||||
SELECT kc.`constraint_name`, kc.`column_name`,
|
||||
kc.`referenced_table_name`, kc.`referenced_column_name`
|
||||
FROM information_schema.key_column_usage AS kc
|
||||
WHERE
|
||||
kc.table_schema = DATABASE() AND
|
||||
kc.table_name = %s
|
||||
ORDER BY kc.`ordinal_position`
|
||||
"""
|
||||
cursor.execute(name_query, [table_name])
|
||||
for constraint, column, ref_table, ref_column in cursor.fetchall():
|
||||
if constraint not in constraints:
|
||||
constraints[constraint] = {
|
||||
"columns": OrderedSet(),
|
||||
"primary_key": False,
|
||||
"unique": False,
|
||||
"index": False,
|
||||
"check": False,
|
||||
"foreign_key": (ref_table, ref_column) if ref_column else None,
|
||||
}
|
||||
if self.connection.features.supports_index_column_ordering:
|
||||
constraints[constraint]["orders"] = []
|
||||
constraints[constraint]["columns"].add(column)
|
||||
# Now get the constraint types
|
||||
type_query = """
|
||||
SELECT c.constraint_name, c.constraint_type
|
||||
FROM information_schema.table_constraints AS c
|
||||
WHERE
|
||||
c.table_schema = DATABASE() AND
|
||||
c.table_name = %s
|
||||
"""
|
||||
cursor.execute(type_query, [table_name])
|
||||
for constraint, kind in cursor.fetchall():
|
||||
if kind.lower() == "primary key":
|
||||
constraints[constraint]["primary_key"] = True
|
||||
constraints[constraint]["unique"] = True
|
||||
elif kind.lower() == "unique":
|
||||
constraints[constraint]["unique"] = True
|
||||
# Add check constraints.
|
||||
if self.connection.features.can_introspect_check_constraints:
|
||||
unnamed_constraints_index = 0
|
||||
columns = {
|
||||
info.name for info in self.get_table_description(cursor, table_name)
|
||||
}
|
||||
type_query = """
|
||||
SELECT cc.constraint_name, cc.check_clause
|
||||
FROM
|
||||
information_schema.check_constraints AS cc,
|
||||
information_schema.table_constraints AS tc
|
||||
WHERE
|
||||
cc.constraint_schema = DATABASE() AND
|
||||
tc.table_schema = cc.constraint_schema AND
|
||||
cc.constraint_name = tc.constraint_name AND
|
||||
tc.constraint_type = 'CHECK' AND
|
||||
tc.table_name = %s
|
||||
"""
|
||||
cursor.execute(type_query, [table_name])
|
||||
for constraint, check_clause in cursor.fetchall():
|
||||
constraint_columns = self._parse_constraint_columns(
|
||||
check_clause, columns
|
||||
)
|
||||
# Ensure uniqueness of unnamed constraints. Unnamed unique
|
||||
# and check columns constraints have the same name as
|
||||
# a column.
|
||||
if set(constraint_columns) == {constraint}:
|
||||
unnamed_constraints_index += 1
|
||||
constraint = f"__unnamed_constraint_{unnamed_constraints_index}__"
|
||||
constraints[constraint] = {
|
||||
"columns": constraint_columns,
|
||||
"primary_key": False,
|
||||
"unique": False,
|
||||
"index": False,
|
||||
"check": True,
|
||||
"foreign_key": None,
|
||||
}
|
||||
# Now add in the indexes
|
||||
cursor.execute(f"SHOW INDEX FROM {self.connection.ops.quote_name(table_name)}")
|
||||
for _, _, index, _, column, order, type_ in [
|
||||
x[:6] + (x[10],) for x in cursor.fetchall()
|
||||
]:
|
||||
if index not in constraints:
|
||||
constraints[index] = {
|
||||
"columns": OrderedSet(),
|
||||
"primary_key": False,
|
||||
"unique": False,
|
||||
"check": False,
|
||||
"foreign_key": None,
|
||||
}
|
||||
if self.connection.features.supports_index_column_ordering:
|
||||
constraints[index]["orders"] = []
|
||||
constraints[index]["index"] = True
|
||||
constraints[index]["type"] = (
|
||||
Index.suffix if type_ == "BTREE" else type_.lower()
|
||||
)
|
||||
constraints[index]["columns"].add(column)
|
||||
if self.connection.features.supports_index_column_ordering:
|
||||
constraints[index]["orders"].append("DESC" if order == "D" else "ASC")
|
||||
# Convert the sorted sets to lists
|
||||
for constraint in constraints.values():
|
||||
constraint["columns"] = list(constraint["columns"])
|
||||
return constraints
|
||||
@@ -0,0 +1,104 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="override,attr-defined"
|
||||
|
||||
"""Database Operations."""
|
||||
|
||||
from datetime import datetime, time, timezone
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.backends.mysql.operations import (
|
||||
DatabaseOperations as MySQLDatabaseOperations,
|
||||
)
|
||||
from django.utils import timezone as django_timezone
|
||||
|
||||
try:
|
||||
from _mysql_connector import datetime_to_mysql, time_to_mysql
|
||||
except ImportError:
|
||||
HAVE_CEXT = False
|
||||
else:
|
||||
HAVE_CEXT = True
|
||||
|
||||
|
||||
class DatabaseOperations(MySQLDatabaseOperations):
|
||||
"""Database Operations class."""
|
||||
|
||||
compiler_module = "mysql.connector.django.compiler"
|
||||
|
||||
def regex_lookup(self, lookup_type: str) -> str:
|
||||
"""Return the string to use in a query when performing regular
|
||||
expression lookup."""
|
||||
if self.connection.mysql_version < (8, 0, 0):
|
||||
if lookup_type == "regex":
|
||||
return "%s REGEXP BINARY %s"
|
||||
return "%s REGEXP %s"
|
||||
|
||||
match_option = "c" if lookup_type == "regex" else "i"
|
||||
return f"REGEXP_LIKE(%s, %s, '{match_option}')"
|
||||
|
||||
def adapt_datetimefield_value(self, value: Optional[datetime]) -> Optional[bytes]:
|
||||
"""Transform a datetime value to an object compatible with what is
|
||||
expected by the backend driver for datetime columns."""
|
||||
return self.value_to_db_datetime(value)
|
||||
|
||||
def value_to_db_datetime(self, value: Optional[datetime]) -> Optional[bytes]:
|
||||
"""Convert value to MySQL DATETIME."""
|
||||
ans: Optional[bytes] = None
|
||||
if value is None:
|
||||
return ans
|
||||
# MySQL doesn't support tz-aware times
|
||||
if django_timezone.is_aware(value):
|
||||
if settings.USE_TZ:
|
||||
value = value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
else:
|
||||
raise ValueError("MySQL backend does not support timezone-aware times")
|
||||
if not self.connection.features.supports_microsecond_precision:
|
||||
value = value.replace(microsecond=0)
|
||||
if not self.connection.use_pure:
|
||||
return datetime_to_mysql(value)
|
||||
return self.connection.converter.to_mysql(value)
|
||||
|
||||
def adapt_timefield_value(self, value: Optional[time]) -> Optional[bytes]:
|
||||
"""Transform a time value to an object compatible with what is expected
|
||||
by the backend driver for time columns."""
|
||||
return self.value_to_db_time(value)
|
||||
|
||||
def value_to_db_time(self, value: Optional[time]) -> Optional[bytes]:
|
||||
"""Convert value to MySQL TIME."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# MySQL doesn't support tz-aware times
|
||||
if django_timezone.is_aware(value):
|
||||
raise ValueError("MySQL backend does not support timezone-aware times")
|
||||
|
||||
if not self.connection.use_pure:
|
||||
return time_to_mysql(value)
|
||||
return self.connection.converter.to_mysql(value)
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="override"
|
||||
|
||||
"""Database schema editor."""
|
||||
from typing import Any
|
||||
|
||||
from django.db.backends.mysql.schema import (
|
||||
DatabaseSchemaEditor as MySQLDatabaseSchemaEditor,
|
||||
)
|
||||
|
||||
|
||||
class DatabaseSchemaEditor(MySQLDatabaseSchemaEditor):
|
||||
"""This class is responsible for emitting schema-changing statements to the
|
||||
databases.
|
||||
"""
|
||||
|
||||
def quote_value(self, value: Any) -> Any:
|
||||
"""Quote value."""
|
||||
self.connection.ensure_connection()
|
||||
if isinstance(value, str):
|
||||
value = value.replace("%", "%%")
|
||||
quoted = self.connection.connection.converter.escape(value)
|
||||
if isinstance(value, str) and isinstance(quoted, bytes):
|
||||
quoted = quoted.decode()
|
||||
return quoted
|
||||
|
||||
def prepare_default(self, value: Any) -> Any:
|
||||
"""Implement the required abstract method.
|
||||
|
||||
MySQL has requires_literal_defaults=False, therefore return the value.
|
||||
"""
|
||||
return value
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2020, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Backend specific database validation."""
|
||||
|
||||
from django.db.backends.mysql.validation import DatabaseValidation
|
||||
|
||||
__all__ = ["DatabaseValidation"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,393 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Python exceptions."""
|
||||
from typing import Dict, Mapping, Optional, Tuple, Type, Union
|
||||
|
||||
from .locales import get_client_error
|
||||
from .types import StrOrBytes
|
||||
from .utils import read_bytes, read_int
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""Exception that is base class for all other error exceptions.
|
||||
|
||||
See [1] for more details.
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-api-errors-error.html
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
msg: Optional[str] = None,
|
||||
errno: Optional[int] = None,
|
||||
values: Optional[Tuple[Union[int, str], ...]] = None,
|
||||
sqlstate: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.msg = msg
|
||||
self._full_msg = self.msg
|
||||
self.errno = errno or -1
|
||||
self.sqlstate = sqlstate
|
||||
|
||||
if not self.msg and (2000 <= self.errno < 3000):
|
||||
self.msg = get_client_error(self.errno)
|
||||
if values is not None:
|
||||
try:
|
||||
self.msg = self.msg % values
|
||||
except TypeError as err:
|
||||
self.msg = f"{self.msg} (Warning: {err})"
|
||||
elif not self.msg:
|
||||
self._full_msg = self.msg = "Unknown error"
|
||||
|
||||
if self.msg and self.errno != -1:
|
||||
fields = {"errno": self.errno, "msg": self.msg}
|
||||
if self.sqlstate:
|
||||
fmt = "{errno} ({state}): {msg}"
|
||||
fields["state"] = self.sqlstate
|
||||
else:
|
||||
fmt = "{errno}: {msg}"
|
||||
self._full_msg = fmt.format(**fields)
|
||||
|
||||
self.args = (self.errno, self._full_msg, self.sqlstate)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self._full_msg
|
||||
|
||||
|
||||
class Warning(Exception): # pylint: disable=redefined-builtin
|
||||
"""Exception for important warnings"""
|
||||
|
||||
|
||||
class InterfaceError(Error):
|
||||
"""Exception for errors related to the interface"""
|
||||
|
||||
|
||||
class DatabaseError(Error):
|
||||
"""Exception for errors related to the database"""
|
||||
|
||||
|
||||
class InternalError(DatabaseError):
|
||||
"""Exception for errors internal database errors"""
|
||||
|
||||
|
||||
class OperationalError(DatabaseError):
|
||||
"""Exception for errors related to the database's operation"""
|
||||
|
||||
|
||||
class ProgrammingError(DatabaseError):
|
||||
"""Exception for errors programming errors"""
|
||||
|
||||
|
||||
class IntegrityError(DatabaseError):
|
||||
"""Exception for errors regarding relational integrity"""
|
||||
|
||||
|
||||
class DataError(DatabaseError):
|
||||
"""Exception for errors reporting problems with processed data"""
|
||||
|
||||
|
||||
class NotSupportedError(DatabaseError):
|
||||
"""Exception for errors when an unsupported database feature was used"""
|
||||
|
||||
|
||||
class PoolError(Error):
|
||||
"""Exception for errors relating to connection pooling"""
|
||||
|
||||
|
||||
class ConnectionTimeoutError(Error):
|
||||
"""
|
||||
Exception for errors related to the socket connection timing out while connecting with
|
||||
the server
|
||||
"""
|
||||
|
||||
|
||||
class ReadTimeoutError(Error):
|
||||
"""Exception for errors relating to socket timing out while receiving data from the server"""
|
||||
|
||||
DEFAULT_READ_TIMEOUT_ERROR_MSG: str = """
|
||||
The Read Operation timed out. As a consequence the current connection has been closed to avoid
|
||||
any unstable behaviour, consider using the reconnect() option and continue with the current
|
||||
session's workflow.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
msg: Optional[str] = None,
|
||||
errno: Optional[int] = None,
|
||||
values: Optional[Tuple[str, int]] = None,
|
||||
sqlstate: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(self.DEFAULT_READ_TIMEOUT_ERROR_MSG, errno, values, sqlstate)
|
||||
|
||||
|
||||
class WriteTimeoutError(Error):
|
||||
"""Exception for errors relating to socket timing out while sending data to the server"""
|
||||
|
||||
DEFAULT_WRITE_TIMEOUT_ERROR_MSG: str = """
|
||||
The Write Operation timed out. As a consequence the current connection has been closed to avoid
|
||||
any unstable behaviour, consider using the reconnect() option to continue with the current
|
||||
session's workflow.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
msg: Optional[str] = None,
|
||||
errno: Optional[int] = None,
|
||||
values: Optional[Tuple[str, int]] = None,
|
||||
sqlstate: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(self.DEFAULT_WRITE_TIMEOUT_ERROR_MSG, errno, values, sqlstate)
|
||||
|
||||
|
||||
ErrorClassTypes = Union[
|
||||
Type[Error],
|
||||
Type[InterfaceError],
|
||||
Type[DatabaseError],
|
||||
Type[InternalError],
|
||||
Type[OperationalError],
|
||||
Type[ProgrammingError],
|
||||
Type[IntegrityError],
|
||||
Type[DataError],
|
||||
Type[NotSupportedError],
|
||||
Type[PoolError],
|
||||
Type[ConnectionTimeoutError],
|
||||
Type[ReadTimeoutError],
|
||||
Type[WriteTimeoutError],
|
||||
]
|
||||
ErrorTypes = Union[
|
||||
Error,
|
||||
InterfaceError,
|
||||
DatabaseError,
|
||||
InternalError,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
IntegrityError,
|
||||
DataError,
|
||||
NotSupportedError,
|
||||
PoolError,
|
||||
Warning,
|
||||
ConnectionTimeoutError,
|
||||
ReadTimeoutError,
|
||||
WriteTimeoutError,
|
||||
]
|
||||
# _CUSTOM_ERROR_EXCEPTIONS holds custom exceptions and is used by the
|
||||
# function custom_error_exception. _ERROR_EXCEPTIONS (at bottom of module)
|
||||
# is similar, but hardcoded exceptions.
|
||||
_CUSTOM_ERROR_EXCEPTIONS: Dict[int, ErrorClassTypes] = {}
|
||||
|
||||
|
||||
def custom_error_exception(
|
||||
error: Optional[Union[int, Dict[int, Optional[ErrorClassTypes]]]] = None,
|
||||
exception: Optional[ErrorClassTypes] = None,
|
||||
) -> Mapping[int, Optional[ErrorClassTypes]]:
|
||||
"""Defines custom exceptions for MySQL server errors.
|
||||
|
||||
This function defines custom exceptions for MySQL server errors and
|
||||
returns the current set customizations.
|
||||
|
||||
To reset the customizations, simply supply an empty dictionary.
|
||||
|
||||
Args:
|
||||
error: Can be a MySQL Server error number or a dictionary in which case the
|
||||
key is the server error number and the value is the exception to be raised.
|
||||
exception: If `error` is a MySQL Server error number then you have to pass
|
||||
also the exception class, otherwise you don't.
|
||||
|
||||
Returns:
|
||||
dictionary: Current set customizations.
|
||||
|
||||
Examples:
|
||||
```
|
||||
import mysql.connector
|
||||
from mysql.connector import errorcode
|
||||
|
||||
# Server error 1028 should raise a DatabaseError
|
||||
mysql.connector.custom_error_exception(
|
||||
1028, mysql.connector.DatabaseError)
|
||||
|
||||
# Or using a dictionary:
|
||||
mysql.connector.custom_error_exception({
|
||||
1028: mysql.connector.DatabaseError,
|
||||
1029: mysql.connector.OperationalError,
|
||||
})
|
||||
|
||||
# Reset
|
||||
mysql.connector.custom_error_exception({})
|
||||
```
|
||||
"""
|
||||
global _CUSTOM_ERROR_EXCEPTIONS # pylint: disable=global-statement
|
||||
|
||||
if isinstance(error, dict) and not error:
|
||||
_CUSTOM_ERROR_EXCEPTIONS = {}
|
||||
return _CUSTOM_ERROR_EXCEPTIONS
|
||||
|
||||
if not error and not exception:
|
||||
return _CUSTOM_ERROR_EXCEPTIONS
|
||||
|
||||
if not isinstance(error, (int, dict)):
|
||||
raise ValueError("The error argument should be either an integer or dictionary")
|
||||
|
||||
if isinstance(error, int):
|
||||
error = {error: exception}
|
||||
|
||||
for errno, _exception in error.items():
|
||||
if not isinstance(errno, int):
|
||||
raise ValueError("Error number should be an integer")
|
||||
try:
|
||||
if _exception is None or not issubclass(_exception, Exception):
|
||||
raise TypeError
|
||||
except TypeError as err:
|
||||
raise ValueError("Exception should be subclass of Exception") from err
|
||||
_CUSTOM_ERROR_EXCEPTIONS[errno] = _exception
|
||||
|
||||
return _CUSTOM_ERROR_EXCEPTIONS
|
||||
|
||||
|
||||
def get_mysql_exception(
|
||||
errno: int,
|
||||
msg: Optional[str] = None,
|
||||
sqlstate: Optional[str] = None,
|
||||
warning: Optional[bool] = False,
|
||||
) -> ErrorTypes:
|
||||
"""Get the exception matching the MySQL error
|
||||
|
||||
This function will return an exception based on the SQLState. The given
|
||||
message will be passed on in the returned exception.
|
||||
|
||||
The exception returned can be customized using the
|
||||
mysql.connector.custom_error_exception() function.
|
||||
|
||||
Returns an Exception
|
||||
"""
|
||||
try:
|
||||
return _CUSTOM_ERROR_EXCEPTIONS[errno](msg=msg, errno=errno, sqlstate=sqlstate)
|
||||
except KeyError:
|
||||
# Error was not mapped to particular exception
|
||||
pass
|
||||
|
||||
try:
|
||||
return _ERROR_EXCEPTIONS[errno](msg=msg, errno=errno, sqlstate=sqlstate)
|
||||
except KeyError:
|
||||
# Error was not mapped to particular exception
|
||||
pass
|
||||
|
||||
if not sqlstate:
|
||||
if warning:
|
||||
return Warning(errno, msg)
|
||||
return DatabaseError(msg=msg, errno=errno)
|
||||
|
||||
try:
|
||||
return _SQLSTATE_CLASS_EXCEPTION[sqlstate[0:2]](
|
||||
msg=msg, errno=errno, sqlstate=sqlstate
|
||||
)
|
||||
except KeyError:
|
||||
# Return default InterfaceError
|
||||
return DatabaseError(msg=msg, errno=errno, sqlstate=sqlstate)
|
||||
|
||||
|
||||
def get_exception(packet: bytes) -> ErrorTypes:
|
||||
"""Returns an exception object based on the MySQL error
|
||||
|
||||
Returns an exception object based on the MySQL error in the given
|
||||
packet.
|
||||
|
||||
Returns an Error-Object.
|
||||
"""
|
||||
errno = errmsg = None
|
||||
|
||||
try:
|
||||
if packet[4] != 255:
|
||||
raise ValueError("Packet is not an error packet")
|
||||
except IndexError as err:
|
||||
return InterfaceError(f"Failed getting Error information ({err})")
|
||||
|
||||
sqlstate: Optional[StrOrBytes] = None
|
||||
try:
|
||||
packet = packet[5:]
|
||||
packet, errno = read_int(packet, 2)
|
||||
if packet[0] != 35:
|
||||
# Error without SQLState
|
||||
if isinstance(packet, (bytes, bytearray)):
|
||||
errmsg = packet.decode("utf8")
|
||||
else:
|
||||
errmsg = packet
|
||||
else:
|
||||
packet, sqlstate = read_bytes(packet[1:], 5)
|
||||
sqlstate = sqlstate.decode("utf8")
|
||||
errmsg = packet.decode("utf8")
|
||||
except (IndexError, UnicodeError) as err:
|
||||
return InterfaceError(f"Failed getting Error information ({err})")
|
||||
return get_mysql_exception(errno, errmsg, sqlstate) # type: ignore[arg-type]
|
||||
|
||||
|
||||
_SQLSTATE_CLASS_EXCEPTION: Dict[str, ErrorClassTypes] = {
|
||||
"02": DataError, # no data
|
||||
"07": DatabaseError, # dynamic SQL error
|
||||
"08": OperationalError, # connection exception
|
||||
"0A": NotSupportedError, # feature not supported
|
||||
"21": DataError, # cardinality violation
|
||||
"22": DataError, # data exception
|
||||
"23": IntegrityError, # integrity constraint violation
|
||||
"24": ProgrammingError, # invalid cursor state
|
||||
"25": ProgrammingError, # invalid transaction state
|
||||
"26": ProgrammingError, # invalid SQL statement name
|
||||
"27": ProgrammingError, # triggered data change violation
|
||||
"28": ProgrammingError, # invalid authorization specification
|
||||
"2A": ProgrammingError, # direct SQL syntax error or access rule violation
|
||||
"2B": DatabaseError, # dependent privilege descriptors still exist
|
||||
"2C": ProgrammingError, # invalid character set name
|
||||
"2D": DatabaseError, # invalid transaction termination
|
||||
"2E": DatabaseError, # invalid connection name
|
||||
"33": DatabaseError, # invalid SQL descriptor name
|
||||
"34": ProgrammingError, # invalid cursor name
|
||||
"35": ProgrammingError, # invalid condition number
|
||||
"37": ProgrammingError, # dynamic SQL syntax error or access rule violation
|
||||
"3C": ProgrammingError, # ambiguous cursor name
|
||||
"3D": ProgrammingError, # invalid catalog name
|
||||
"3F": ProgrammingError, # invalid schema name
|
||||
"40": InternalError, # transaction rollback
|
||||
"42": ProgrammingError, # syntax error or access rule violation
|
||||
"44": InternalError, # with check option violation
|
||||
"HZ": OperationalError, # remote database access
|
||||
"XA": IntegrityError,
|
||||
"0K": OperationalError,
|
||||
"HY": DatabaseError, # default when no SQLState provided by MySQL server
|
||||
}
|
||||
|
||||
_ERROR_EXCEPTIONS: Dict[int, ErrorClassTypes] = {
|
||||
1243: ProgrammingError,
|
||||
1210: ProgrammingError,
|
||||
2002: InterfaceError,
|
||||
2013: OperationalError,
|
||||
2049: NotSupportedError,
|
||||
2055: OperationalError,
|
||||
2061: InterfaceError,
|
||||
2026: InterfaceError,
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Copyright (c) 2012, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Translations."""
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
__all__: List[str] = ["get_client_error"]
|
||||
|
||||
from .. import errorcode
|
||||
|
||||
|
||||
def get_client_error(error: Union[int, str], language: str = "eng") -> Optional[str]:
|
||||
"""Lookup client error
|
||||
|
||||
This function will lookup the client error message based on the given
|
||||
error and return the error message. If the error was not found,
|
||||
None will be returned.
|
||||
|
||||
Error can be either an integer or a string. For example:
|
||||
error: 2000
|
||||
error: CR_UNKNOWN_ERROR
|
||||
|
||||
The language attribute can be used to retrieve a localized message, when
|
||||
available.
|
||||
|
||||
Returns a string or None.
|
||||
"""
|
||||
try:
|
||||
tmp = __import__(
|
||||
f"mysql.connector.locales.{language}",
|
||||
globals(),
|
||||
locals(),
|
||||
["client_error"],
|
||||
)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
f"No localization support for language '{language}'"
|
||||
) from None
|
||||
client_error = tmp.client_error
|
||||
|
||||
if isinstance(error, int):
|
||||
errno = error
|
||||
for key, value in errorcode.__dict__.items():
|
||||
if value == errno:
|
||||
error = key
|
||||
break
|
||||
|
||||
if isinstance(error, (str)):
|
||||
try:
|
||||
return getattr(client_error, error)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
raise ValueError("error argument needs to be either an integer or string")
|
||||
@@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2012, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""English Content
|
||||
"""
|
||||
@@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (c) 2013, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""MySQL Error Messages."""
|
||||
|
||||
# This file was auto-generated.
|
||||
_GENERATED_ON = "2021-08-11"
|
||||
_MYSQL_VERSION = (8, 0, 27)
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
# Start MySQL Error messages
|
||||
CR_UNKNOWN_ERROR = "Unknown MySQL error"
|
||||
CR_SOCKET_CREATE_ERROR = "Can't create UNIX socket (%s)"
|
||||
CR_CONNECTION_ERROR = (
|
||||
"Can't connect to local MySQL server through socket '%-.100s' (%s)"
|
||||
)
|
||||
CR_CONN_HOST_ERROR = "Can't connect to MySQL server on '%-.100s:%u' (%s)"
|
||||
CR_IPSOCK_ERROR = "Can't create TCP/IP socket (%s)"
|
||||
CR_UNKNOWN_HOST = "Unknown MySQL server host '%-.100s' (%s)"
|
||||
CR_SERVER_GONE_ERROR = "MySQL server has gone away"
|
||||
CR_VERSION_ERROR = "Protocol mismatch; server version = %s, client version = %s"
|
||||
CR_OUT_OF_MEMORY = "MySQL client ran out of memory"
|
||||
CR_WRONG_HOST_INFO = "Wrong host info"
|
||||
CR_LOCALHOST_CONNECTION = "Localhost via UNIX socket"
|
||||
CR_TCP_CONNECTION = "%-.100s via TCP/IP"
|
||||
CR_SERVER_HANDSHAKE_ERR = "Error in server handshake"
|
||||
CR_SERVER_LOST = "Lost connection to MySQL server during query"
|
||||
CR_COMMANDS_OUT_OF_SYNC = "Commands out of sync; you can't run this command now"
|
||||
CR_NAMEDPIPE_CONNECTION = "Named pipe: %-.32s"
|
||||
CR_NAMEDPIPEWAIT_ERROR = "Can't wait for named pipe to host: %-.64s pipe: %-.32s (%s)"
|
||||
CR_NAMEDPIPEOPEN_ERROR = "Can't open named pipe to host: %-.64s pipe: %-.32s (%s)"
|
||||
CR_NAMEDPIPESETSTATE_ERROR = (
|
||||
"Can't set state of named pipe to host: %-.64s pipe: %-.32s (%s)"
|
||||
)
|
||||
CR_CANT_READ_CHARSET = "Can't initialize character set %-.32s (path: %-.100s)"
|
||||
CR_NET_PACKET_TOO_LARGE = "Got packet bigger than 'max_allowed_packet' bytes"
|
||||
CR_EMBEDDED_CONNECTION = "Embedded server"
|
||||
CR_PROBE_SLAVE_STATUS = "Error on SHOW SLAVE STATUS:"
|
||||
CR_PROBE_SLAVE_HOSTS = "Error on SHOW SLAVE HOSTS:"
|
||||
CR_PROBE_SLAVE_CONNECT = "Error connecting to slave:"
|
||||
CR_PROBE_MASTER_CONNECT = "Error connecting to master:"
|
||||
CR_SSL_CONNECTION_ERROR = "SSL connection error: %-.100s"
|
||||
CR_MALFORMED_PACKET = "Malformed packet"
|
||||
CR_WRONG_LICENSE = "This client library is licensed only for use with MySQL servers having '%s' license"
|
||||
CR_NULL_POINTER = "Invalid use of null pointer"
|
||||
CR_NO_PREPARE_STMT = "Statement not prepared"
|
||||
CR_PARAMS_NOT_BOUND = "No data supplied for parameters in prepared statement"
|
||||
CR_DATA_TRUNCATED = "Data truncated"
|
||||
CR_NO_PARAMETERS_EXISTS = "No parameters exist in the statement"
|
||||
CR_INVALID_PARAMETER_NO = "Invalid parameter number"
|
||||
CR_INVALID_BUFFER_USE = (
|
||||
"Can't send long data for non-string/non-binary data types (parameter: %s)"
|
||||
)
|
||||
CR_UNSUPPORTED_PARAM_TYPE = "Using unsupported buffer type: %s (parameter: %s)"
|
||||
CR_SHARED_MEMORY_CONNECTION = "Shared memory: %-.100s"
|
||||
CR_SHARED_MEMORY_CONNECT_REQUEST_ERROR = (
|
||||
"Can't open shared memory; client could not create request event (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_CONNECT_ANSWER_ERROR = (
|
||||
"Can't open shared memory; no answer event received from server (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_CONNECT_FILE_MAP_ERROR = (
|
||||
"Can't open shared memory; server could not allocate file mapping (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_CONNECT_MAP_ERROR = (
|
||||
"Can't open shared memory; server could not get pointer to file mapping (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_FILE_MAP_ERROR = (
|
||||
"Can't open shared memory; client could not allocate file mapping (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_MAP_ERROR = (
|
||||
"Can't open shared memory; client could not get pointer to file mapping (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_EVENT_ERROR = (
|
||||
"Can't open shared memory; client could not create %s event (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_CONNECT_ABANDONED_ERROR = (
|
||||
"Can't open shared memory; no answer from server (%s)"
|
||||
)
|
||||
CR_SHARED_MEMORY_CONNECT_SET_ERROR = (
|
||||
"Can't open shared memory; cannot send request event to server (%s)"
|
||||
)
|
||||
CR_CONN_UNKNOW_PROTOCOL = "Wrong or unknown protocol"
|
||||
CR_INVALID_CONN_HANDLE = "Invalid connection handle"
|
||||
CR_UNUSED_1 = "Connection using old (pre-4.1.1) authentication protocol refused (client option 'secure_auth' enabled)"
|
||||
CR_FETCH_CANCELED = "Row retrieval was canceled by mysql_stmt_close() call"
|
||||
CR_NO_DATA = "Attempt to read column without prior row fetch"
|
||||
CR_NO_STMT_METADATA = "Prepared statement contains no metadata"
|
||||
CR_NO_RESULT_SET = (
|
||||
"Attempt to read a row while there is no result set associated with the statement"
|
||||
)
|
||||
CR_NOT_IMPLEMENTED = "This feature is not implemented yet"
|
||||
CR_SERVER_LOST_EXTENDED = "Lost connection to MySQL server at '%s', system error: %s"
|
||||
CR_STMT_CLOSED = "Statement closed indirectly because of a preceding %s() call"
|
||||
CR_NEW_STMT_METADATA = "The number of columns in the result set differs from the number of bound buffers. You must reset the statement, rebind the result set columns, and execute the statement again"
|
||||
CR_ALREADY_CONNECTED = (
|
||||
"This handle is already connected. Use a separate handle for each connection."
|
||||
)
|
||||
CR_AUTH_PLUGIN_CANNOT_LOAD = "Authentication plugin '%s' cannot be loaded: %s"
|
||||
CR_DUPLICATE_CONNECTION_ATTR = "There is an attribute with the same name already"
|
||||
CR_AUTH_PLUGIN_ERR = "Authentication plugin '%s' reported error: %s"
|
||||
CR_INSECURE_API_ERR = "Insecure API function call: '%s' Use instead: '%s'"
|
||||
CR_FILE_NAME_TOO_LONG = "File name is too long"
|
||||
CR_SSL_FIPS_MODE_ERR = "Set FIPS mode ON/STRICT failed"
|
||||
CR_DEPRECATED_COMPRESSION_NOT_SUPPORTED = (
|
||||
"Compression protocol not supported with asynchronous protocol"
|
||||
)
|
||||
CR_COMPRESSION_WRONGLY_CONFIGURED = (
|
||||
"Connection failed due to wrongly configured compression algorithm"
|
||||
)
|
||||
CR_KERBEROS_USER_NOT_FOUND = (
|
||||
"SSO user not found, Please perform SSO authentication using kerberos."
|
||||
)
|
||||
CR_LOAD_DATA_LOCAL_INFILE_REJECTED = (
|
||||
"LOAD DATA LOCAL INFILE file request rejected due to restrictions on access."
|
||||
)
|
||||
CR_LOAD_DATA_LOCAL_INFILE_REALPATH_FAIL = (
|
||||
"Determining the real path for '%s' failed with error (%s): %s"
|
||||
)
|
||||
CR_DNS_SRV_LOOKUP_FAILED = "DNS SRV lookup failed with error : %s"
|
||||
CR_MANDATORY_TRACKER_NOT_FOUND = (
|
||||
"Client does not recognise tracker type %s marked as mandatory by server."
|
||||
)
|
||||
CR_INVALID_FACTOR_NO = "Invalid first argument for MYSQL_OPT_USER_PASSWORD option. Valid value should be between 1 and 3 inclusive."
|
||||
# End MySQL Error messages
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2022, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Setup of the `mysql.connector` logger."""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("mysql.connector")
|
||||
@@ -0,0 +1,811 @@
|
||||
# Copyright (c) 2012, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Module implementing low-level socket communication with MySQL servers."""
|
||||
|
||||
# pylint: disable=overlapping-except
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import warnings
|
||||
import zlib
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import deque
|
||||
from typing import Any, Deque, List, Optional, Tuple, Union
|
||||
|
||||
try:
|
||||
import ssl
|
||||
|
||||
TLS_VERSIONS = {
|
||||
"TLSv1": ssl.PROTOCOL_TLSv1,
|
||||
"TLSv1.1": ssl.PROTOCOL_TLSv1_1,
|
||||
"TLSv1.2": ssl.PROTOCOL_TLSv1_2,
|
||||
"TLSv1.3": ssl.PROTOCOL_TLS,
|
||||
}
|
||||
TLS_V1_3_SUPPORTED = hasattr(ssl, "HAS_TLSv1_3") and ssl.HAS_TLSv1_3
|
||||
except ImportError:
|
||||
# If import fails, we don't have SSL support.
|
||||
TLS_V1_3_SUPPORTED = False
|
||||
ssl = None
|
||||
|
||||
from .errors import (
|
||||
ConnectionTimeoutError,
|
||||
InterfaceError,
|
||||
NotSupportedError,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
ReadTimeoutError,
|
||||
WriteTimeoutError,
|
||||
)
|
||||
|
||||
MIN_COMPRESS_LENGTH = 50
|
||||
MAX_PAYLOAD_LENGTH = 2**24 - 1
|
||||
PACKET_HEADER_LENGTH = 4
|
||||
COMPRESSED_PACKET_HEADER_LENGTH = 7
|
||||
|
||||
|
||||
def _strioerror(err: IOError) -> str:
|
||||
"""Reformat the IOError error message.
|
||||
|
||||
This function reformats the IOError error message.
|
||||
"""
|
||||
return str(err) if not err.errno else f"Errno {err.errno}: {err.strerror}"
|
||||
|
||||
|
||||
class NetworkBroker(ABC):
|
||||
"""Broker class interface.
|
||||
|
||||
The network object is a broker used as a delegate by a socket object. Whenever the
|
||||
socket wants to deliver or get packets to or from the MySQL server it needs to rely
|
||||
on its network broker (netbroker).
|
||||
|
||||
The netbroker sends `payloads` and receives `packets`.
|
||||
|
||||
A packet is a bytes sequence, it has a header and body (referred to as payload).
|
||||
The first `PACKET_HEADER_LENGTH` or `COMPRESSED_PACKET_HEADER_LENGTH`
|
||||
(as appropriate) bytes correspond to the `header`, the remaining ones represent the
|
||||
`payload`.
|
||||
|
||||
The maximum payload length allowed to be sent per packet to the server is
|
||||
`MAX_PAYLOAD_LENGTH`. When `send` is called with a payload whose length is greater
|
||||
than `MAX_PAYLOAD_LENGTH` the netbroker breaks it down into packets, so the caller
|
||||
of `send` can provide payloads of arbitrary length.
|
||||
|
||||
Finally, data received by the netbroker comes directly from the server, expect to
|
||||
get a packet for each call to `recv`. The received packet contains a header and
|
||||
payload, the latter respecting `MAX_PAYLOAD_LENGTH`.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def send(
|
||||
self,
|
||||
sock: socket.socket,
|
||||
address: str,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send `payload` to the MySQL server.
|
||||
|
||||
If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is
|
||||
broken down into packets.
|
||||
|
||||
Args:
|
||||
sock: Object holding the socket connection.
|
||||
address: Socket's location.
|
||||
payload: Packet's body to send.
|
||||
packet_number: Sequence id (packet ID) to attach to the header when sending
|
||||
plain packets.
|
||||
compressed_packet_number: Same as `packet_number` but used when sending
|
||||
compressed packets.
|
||||
|
||||
Raises:
|
||||
:class:`OperationalError`: If something goes wrong while sending packets to
|
||||
the MySQL server.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def recv(self, sock: socket.socket, address: str) -> bytearray:
|
||||
"""Get the next available packet from the MySQL server.
|
||||
|
||||
Args:
|
||||
sock: Object holding the socket connection.
|
||||
address: Socket's location.
|
||||
|
||||
Returns:
|
||||
packet: A packet from the MySQL server.
|
||||
|
||||
Raises:
|
||||
:class:`OperationalError`: If something goes wrong while receiving packets
|
||||
from the MySQL server.
|
||||
:class:`InterfaceError`: If something goes wrong while receiving packets
|
||||
from the MySQL server.
|
||||
"""
|
||||
|
||||
|
||||
class NetworkBrokerPlain(NetworkBroker):
|
||||
"""Broker class for MySQL socket communication."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pktnr: int = -1 # packet number
|
||||
|
||||
def _set_next_pktnr(self) -> None:
|
||||
"""Increment packet id."""
|
||||
self._pktnr = (self._pktnr + 1) % 256
|
||||
|
||||
def _send_pkt(self, sock: socket.socket, address: str, pkt: bytes) -> None:
|
||||
"""Write packet to the comm channel."""
|
||||
try:
|
||||
sock.sendall(pkt)
|
||||
except (socket.timeout, TimeoutError) as err:
|
||||
raise WriteTimeoutError(errno=3024) from err
|
||||
except IOError as err:
|
||||
raise OperationalError(
|
||||
errno=2055, values=(address, _strioerror(err))
|
||||
) from err
|
||||
except AttributeError as err:
|
||||
raise OperationalError(errno=2006) from err
|
||||
|
||||
def _recv_chunk(self, sock: socket.socket, size: int = 0) -> bytearray:
|
||||
"""Read `size` bytes from the comm channel."""
|
||||
pkt = bytearray(size)
|
||||
pkt_view = memoryview(pkt)
|
||||
while size:
|
||||
read = sock.recv_into(pkt_view, size)
|
||||
if read == 0 and size > 0:
|
||||
raise InterfaceError(errno=2013)
|
||||
pkt_view = pkt_view[read:]
|
||||
size -= read
|
||||
return pkt
|
||||
|
||||
def send(
|
||||
self,
|
||||
sock: socket.socket,
|
||||
address: str,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send payload to the MySQL server.
|
||||
|
||||
If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is
|
||||
broken down into packets.
|
||||
"""
|
||||
if packet_number is None:
|
||||
self._set_next_pktnr()
|
||||
else:
|
||||
self._pktnr = packet_number
|
||||
|
||||
# If the payload is larger than or equal to MAX_PAYLOAD_LENGTH
|
||||
# the length is set to 2^24 - 1 (ff ff ff) and additional
|
||||
# packets are sent with the rest of the payload until the
|
||||
# payload of a packet is less than MAX_PAYLOAD_LENGTH.
|
||||
if len(payload) >= MAX_PAYLOAD_LENGTH:
|
||||
offset = 0
|
||||
for _ in range(len(payload) // MAX_PAYLOAD_LENGTH):
|
||||
# payload_len, sequence_id, payload
|
||||
self._send_pkt(
|
||||
sock,
|
||||
address,
|
||||
b"\xff\xff\xff"
|
||||
+ struct.pack("<B", self._pktnr)
|
||||
+ payload[offset : offset + MAX_PAYLOAD_LENGTH],
|
||||
)
|
||||
self._set_next_pktnr()
|
||||
offset += MAX_PAYLOAD_LENGTH
|
||||
payload = payload[offset:]
|
||||
self._send_pkt(
|
||||
sock,
|
||||
address,
|
||||
struct.pack("<I", len(payload))[0:3]
|
||||
+ struct.pack("<B", self._pktnr)
|
||||
+ payload,
|
||||
)
|
||||
|
||||
def recv(self, sock: socket.socket, address: str) -> bytearray:
|
||||
"""Receive `one` packet from the MySQL server."""
|
||||
try:
|
||||
# Read the header of the MySQL packet
|
||||
header = self._recv_chunk(sock, size=PACKET_HEADER_LENGTH)
|
||||
|
||||
# Pull the payload length and sequence id
|
||||
payload_len, self._pktnr = (
|
||||
struct.unpack("<I", header[0:3] + b"\x00")[0],
|
||||
header[3],
|
||||
)
|
||||
|
||||
# Read the payload, and return packet
|
||||
return header + self._recv_chunk(sock, size=payload_len)
|
||||
except (socket.timeout, TimeoutError) as err:
|
||||
raise ReadTimeoutError(errno=3024, msg=err.strerror) from err
|
||||
except IOError as err:
|
||||
raise OperationalError(
|
||||
errno=2055, values=(address, _strioerror(err))
|
||||
) from err
|
||||
|
||||
|
||||
class NetworkBrokerCompressed(NetworkBrokerPlain):
|
||||
"""Broker class for MySQL socket communication."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._compressed_pktnr = -1
|
||||
self._queue_read: Deque[bytearray] = deque()
|
||||
|
||||
@staticmethod
|
||||
def _prepare_packets(payload: bytes, pktnr: int) -> List[bytes]:
|
||||
"""Prepare a payload for sending to the MySQL server."""
|
||||
pkts = []
|
||||
|
||||
# If the payload is larger than or equal to MAX_PAYLOAD_LENGTH
|
||||
# the length is set to 2^24 - 1 (ff ff ff) and additional
|
||||
# packets are sent with the rest of the payload until the
|
||||
# payload of a packet is less than MAX_PAYLOAD_LENGTH.
|
||||
if len(payload) >= MAX_PAYLOAD_LENGTH:
|
||||
offset = 0
|
||||
for _ in range(len(payload) // MAX_PAYLOAD_LENGTH):
|
||||
# payload length + sequence id + payload
|
||||
pkts.append(
|
||||
b"\xff\xff\xff"
|
||||
+ struct.pack("<B", pktnr)
|
||||
+ payload[offset : offset + MAX_PAYLOAD_LENGTH]
|
||||
)
|
||||
pktnr = (pktnr + 1) % 256
|
||||
offset += MAX_PAYLOAD_LENGTH
|
||||
payload = payload[offset:]
|
||||
pkts.append(
|
||||
struct.pack("<I", len(payload))[0:3] + struct.pack("<B", pktnr) + payload
|
||||
)
|
||||
return pkts
|
||||
|
||||
def _set_next_compressed_pktnr(self) -> None:
|
||||
"""Increment packet id."""
|
||||
self._compressed_pktnr = (self._compressed_pktnr + 1) % 256
|
||||
|
||||
def _send_pkt(self, sock: socket.socket, address: str, pkt: bytes) -> None:
|
||||
"""Compress packet and write it to the comm channel."""
|
||||
compressed_pkt = zlib.compress(pkt)
|
||||
pkt = (
|
||||
struct.pack("<I", len(compressed_pkt))[0:3]
|
||||
+ struct.pack("<B", self._compressed_pktnr)
|
||||
+ struct.pack("<I", len(pkt))[0:3]
|
||||
+ compressed_pkt
|
||||
)
|
||||
return super()._send_pkt(sock, address, pkt)
|
||||
|
||||
def send(
|
||||
self,
|
||||
sock: socket.socket,
|
||||
address: str,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send `payload` as compressed packets to the MySQL server.
|
||||
|
||||
If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is
|
||||
broken down into packets.
|
||||
"""
|
||||
# get next packet numbers
|
||||
if packet_number is None:
|
||||
self._set_next_pktnr()
|
||||
else:
|
||||
self._pktnr = packet_number
|
||||
if compressed_packet_number is None:
|
||||
self._set_next_compressed_pktnr()
|
||||
else:
|
||||
self._compressed_pktnr = compressed_packet_number
|
||||
|
||||
payload_prep = bytearray(b"").join(self._prepare_packets(payload, self._pktnr))
|
||||
if len(payload) >= MAX_PAYLOAD_LENGTH - PACKET_HEADER_LENGTH:
|
||||
# sending a MySQL payload of the size greater or equal to 2^24 - 5
|
||||
# via compression leads to at least one extra compressed packet
|
||||
# WHY? let's say len(payload) is MAX_PAYLOAD_LENGTH - 3; when preparing
|
||||
# the payload, a header of size PACKET_HEADER_LENGTH is pre-appended
|
||||
# to the payload. This means that len(payload_prep) is
|
||||
# MAX_PAYLOAD_LENGTH - 3 + PACKET_HEADER_LENGTH = MAX_PAYLOAD_LENGTH + 1
|
||||
# surpassing the maximum allowed payload size per packet.
|
||||
offset = 0
|
||||
|
||||
# send several MySQL packets
|
||||
for _ in range(len(payload_prep) // MAX_PAYLOAD_LENGTH):
|
||||
self._send_pkt(
|
||||
sock, address, payload_prep[offset : offset + MAX_PAYLOAD_LENGTH]
|
||||
)
|
||||
self._set_next_compressed_pktnr()
|
||||
offset += MAX_PAYLOAD_LENGTH
|
||||
self._send_pkt(sock, address, payload_prep[offset:])
|
||||
else:
|
||||
# send one MySQL packet
|
||||
# For small packets it may be too costly to compress the packet.
|
||||
# Usually payloads less than 50 bytes (MIN_COMPRESS_LENGTH)
|
||||
# aren't compressed (see MySQL source code Documentation).
|
||||
if len(payload) > MIN_COMPRESS_LENGTH:
|
||||
# perform compression
|
||||
self._send_pkt(sock, address, payload_prep)
|
||||
else:
|
||||
# skip compression
|
||||
super()._send_pkt(
|
||||
sock,
|
||||
address,
|
||||
struct.pack("<I", len(payload_prep))[0:3]
|
||||
+ struct.pack("<B", self._compressed_pktnr)
|
||||
+ struct.pack("<I", 0)[0:3]
|
||||
+ payload_prep,
|
||||
)
|
||||
|
||||
def _recv_compressed_pkt(
|
||||
self, sock: socket.socket, compressed_pll: int, uncompressed_pll: int
|
||||
) -> None:
|
||||
"""Handle reading of a compressed packet."""
|
||||
# compressed_pll stands for compressed payload length.
|
||||
# Recalling that if uncompressed payload length == 0, the packet
|
||||
# comes in uncompressed, so no decompression is needed.
|
||||
compressed_pkt = super()._recv_chunk(sock, size=compressed_pll)
|
||||
pkt = (
|
||||
compressed_pkt
|
||||
if uncompressed_pll == 0
|
||||
else bytearray(zlib.decompress(compressed_pkt))
|
||||
)
|
||||
|
||||
offset = 0
|
||||
while offset < len(pkt):
|
||||
# pll stands for payload length
|
||||
pll = struct.unpack(
|
||||
"<I", pkt[offset : offset + PACKET_HEADER_LENGTH - 1] + b"\x00"
|
||||
)[0]
|
||||
if PACKET_HEADER_LENGTH + pll > len(pkt) - offset:
|
||||
# More bytes need to be consumed
|
||||
# Read the header of the next MySQL packet
|
||||
header = super()._recv_chunk(sock, size=COMPRESSED_PACKET_HEADER_LENGTH)
|
||||
|
||||
# compressed payload length, sequence id, uncompressed payload length
|
||||
(
|
||||
compressed_pll,
|
||||
self._compressed_pktnr,
|
||||
uncompressed_pll,
|
||||
) = (
|
||||
struct.unpack("<I", header[0:3] + b"\x00")[0],
|
||||
header[3],
|
||||
struct.unpack("<I", header[4:7] + b"\x00")[0],
|
||||
)
|
||||
compressed_pkt = super()._recv_chunk(sock, size=compressed_pll)
|
||||
|
||||
# recalling that if uncompressed payload length == 0, the packet
|
||||
# comes in uncompressed, so no decompression is needed.
|
||||
pkt += (
|
||||
compressed_pkt
|
||||
if uncompressed_pll == 0
|
||||
else zlib.decompress(compressed_pkt)
|
||||
)
|
||||
|
||||
self._queue_read.append(pkt[offset : offset + PACKET_HEADER_LENGTH + pll])
|
||||
offset += PACKET_HEADER_LENGTH + pll
|
||||
|
||||
def recv(self, sock: socket.socket, address: str) -> bytearray:
|
||||
"""Receive `one` or `several` packets from the MySQL server, enqueue them, and
|
||||
return the packet at the head.
|
||||
"""
|
||||
if not self._queue_read:
|
||||
try:
|
||||
# Read the header of the next MySQL packet
|
||||
header = super()._recv_chunk(sock, size=COMPRESSED_PACKET_HEADER_LENGTH)
|
||||
|
||||
# compressed payload length, sequence id, uncompressed payload length
|
||||
(
|
||||
compressed_pll,
|
||||
self._compressed_pktnr,
|
||||
uncompressed_pll,
|
||||
) = (
|
||||
struct.unpack("<I", header[0:3] + b"\x00")[0],
|
||||
header[3],
|
||||
struct.unpack("<I", header[4:7] + b"\x00")[0],
|
||||
)
|
||||
self._recv_compressed_pkt(sock, compressed_pll, uncompressed_pll)
|
||||
except (socket.timeout, TimeoutError) as err:
|
||||
raise ReadTimeoutError(errno=3024) from err
|
||||
except IOError as err:
|
||||
raise OperationalError(
|
||||
errno=2055, values=(address, _strioerror(err))
|
||||
) from err
|
||||
|
||||
if not self._queue_read:
|
||||
return None
|
||||
|
||||
pkt = self._queue_read.popleft()
|
||||
self._pktnr = pkt[3]
|
||||
|
||||
return pkt
|
||||
|
||||
|
||||
class MySQLSocket(ABC):
|
||||
"""MySQL socket communication interface.
|
||||
|
||||
Examples:
|
||||
Subclasses: network.MySQLTCPSocket and network.MySQLUnixSocket.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Network layer where transactions are made with plain (uncompressed) packets
|
||||
is enabled by default.
|
||||
"""
|
||||
# holds the socket connection
|
||||
self.sock: Optional[socket.socket] = None
|
||||
self._connection_timeout: Optional[int] = None
|
||||
self.server_host: Optional[str] = None
|
||||
self._netbroker: NetworkBroker = NetworkBrokerPlain()
|
||||
|
||||
def switch_to_compressed_mode(self) -> None:
|
||||
"""Enable network layer where transactions are made with compressed packets."""
|
||||
self._netbroker = NetworkBrokerCompressed()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shut down the socket before closing it."""
|
||||
try:
|
||||
self.sock.shutdown(socket.SHUT_RDWR)
|
||||
self.sock.close()
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
def close_connection(self) -> None:
|
||||
"""Close the socket."""
|
||||
try:
|
||||
self.sock.close()
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.shutdown()
|
||||
|
||||
def set_connection_timeout(self, timeout: Optional[int]) -> None:
|
||||
"""Set the connection timeout."""
|
||||
self._connection_timeout = timeout
|
||||
if self.sock:
|
||||
self.sock.settimeout(timeout)
|
||||
|
||||
def switch_to_ssl(self, ssl_context: Any, host: str) -> None:
|
||||
"""Upgrade an existing connection to TLS.
|
||||
|
||||
Args:
|
||||
ssl_context (ssl.SSLContext): The SSL Context to be used.
|
||||
host (str): Server host name.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
|
||||
Raises:
|
||||
ProgrammingError: If the transport does not expose the socket instance.
|
||||
NotSupportedError: If Python installation has no SSL support.
|
||||
"""
|
||||
# Ensure that self.sock is already created
|
||||
assert self.sock is not None
|
||||
|
||||
if self.sock.family == 1: # socket.AF_UNIX
|
||||
raise ProgrammingError("SSL is not supported when using Unix sockets")
|
||||
|
||||
if ssl is None:
|
||||
raise NotSupportedError("Python installation has no SSL support")
|
||||
|
||||
try:
|
||||
self.sock = ssl_context.wrap_socket(self.sock, server_hostname=host)
|
||||
except NameError as err:
|
||||
raise NotSupportedError("Python installation has no SSL support") from err
|
||||
except (ssl.SSLError, IOError) as err:
|
||||
raise InterfaceError(
|
||||
errno=2055, values=(self.address, _strioerror(err))
|
||||
) from err
|
||||
except ssl.CertificateError as err:
|
||||
raise InterfaceError(str(err)) from err
|
||||
except NotImplementedError as err:
|
||||
raise InterfaceError(str(err)) from err
|
||||
|
||||
def build_ssl_context(
|
||||
self,
|
||||
ssl_ca: Optional[str] = None,
|
||||
ssl_cert: Optional[str] = None,
|
||||
ssl_key: Optional[str] = None,
|
||||
ssl_verify_cert: Optional[bool] = False,
|
||||
ssl_verify_identity: Optional[bool] = False,
|
||||
tls_versions: Optional[List[str]] = None,
|
||||
tls_cipher_suites: Optional[List[str]] = None,
|
||||
) -> Any:
|
||||
"""Build a SSLContext.
|
||||
|
||||
Args:
|
||||
ssl_ca: Certificate authority, opptional.
|
||||
ssl_cert: SSL certificate, optional.
|
||||
ssl_key: Private key, optional.
|
||||
ssl_verify_cert: Verify the SSL certificate if `True`.
|
||||
ssl_verify_identity: Verify host identity if `True`.
|
||||
tls_versions: TLS protocol versions, optional.
|
||||
tls_cipher_suites: Set of steps that helps to establish a secure connection.
|
||||
|
||||
Returns:
|
||||
ssl_context (ssl.SSLContext): An SSL Context ready be used.
|
||||
|
||||
Raises:
|
||||
NotSupportedError: Python installation has no SSL support.
|
||||
InterfaceError: Socket undefined or invalid ssl data.
|
||||
"""
|
||||
tls_version: Optional[str] = None
|
||||
|
||||
if not self.sock:
|
||||
raise InterfaceError(errno=2048)
|
||||
|
||||
if ssl is None:
|
||||
raise NotSupportedError("Python installation has no SSL support")
|
||||
|
||||
if tls_versions is None:
|
||||
tls_versions = []
|
||||
|
||||
if tls_cipher_suites is None:
|
||||
tls_cipher_suites = []
|
||||
|
||||
try:
|
||||
if tls_versions:
|
||||
tls_versions.sort(reverse=True)
|
||||
tls_version = tls_versions[0]
|
||||
ssl_protocol = TLS_VERSIONS[tls_version]
|
||||
context = ssl.SSLContext(ssl_protocol)
|
||||
|
||||
if tls_version == "TLSv1.3":
|
||||
if "TLSv1.2" not in tls_versions:
|
||||
context.options |= ssl.OP_NO_TLSv1_2
|
||||
if "TLSv1.1" not in tls_versions:
|
||||
context.options |= ssl.OP_NO_TLSv1_1
|
||||
if "TLSv1" not in tls_versions:
|
||||
context.options |= ssl.OP_NO_TLSv1
|
||||
else:
|
||||
# `check_hostname` is True by default
|
||||
context = ssl.create_default_context()
|
||||
|
||||
context.check_hostname = ssl_verify_identity
|
||||
|
||||
if ssl_verify_cert:
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
elif ssl_verify_identity:
|
||||
context.verify_mode = ssl.CERT_OPTIONAL
|
||||
else:
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
context.load_default_certs()
|
||||
|
||||
if ssl_ca:
|
||||
try:
|
||||
context.load_verify_locations(ssl_ca)
|
||||
except (IOError, ssl.SSLError) as err:
|
||||
raise InterfaceError(f"Invalid CA Certificate: {err}") from err
|
||||
if ssl_cert:
|
||||
try:
|
||||
context.load_cert_chain(ssl_cert, ssl_key)
|
||||
except (IOError, ssl.SSLError) as err:
|
||||
raise InterfaceError(f"Invalid Certificate/Key: {err}") from err
|
||||
|
||||
# TLSv1.3 ciphers cannot be disabled with `SSLContext.set_ciphers(...)`,
|
||||
# see https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_ciphers.
|
||||
if tls_cipher_suites and tls_version == "TLSv1.2":
|
||||
context.set_ciphers(":".join(tls_cipher_suites))
|
||||
|
||||
return context
|
||||
except NameError as err:
|
||||
raise NotSupportedError("Python installation has no SSL support") from err
|
||||
except (
|
||||
IOError,
|
||||
NotImplementedError,
|
||||
ssl.CertificateError,
|
||||
ssl.SSLError,
|
||||
) as err:
|
||||
raise InterfaceError(str(err)) from err
|
||||
|
||||
def send(
|
||||
self,
|
||||
payload: bytes,
|
||||
packet_number: Optional[int] = None,
|
||||
compressed_packet_number: Optional[int] = None,
|
||||
write_timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send `payload` to the MySQL server.
|
||||
|
||||
NOTE: if `payload` is an instance of `bytearray`, then `payload` might be
|
||||
changed by this method - `bytearray` is similar to passing a variable by
|
||||
reference.
|
||||
|
||||
If you're sure you won't read `payload` after invoking `send()`,
|
||||
then you can use `bytearray.` Otherwise, you must use `bytes`.
|
||||
"""
|
||||
try:
|
||||
if (
|
||||
not self._connection_timeout and self.sock is not None
|
||||
): # can't update the timeout during connection phase
|
||||
self.sock.settimeout(float(write_timeout) if write_timeout else None)
|
||||
except OSError as _:
|
||||
# Ignore the OSError as the socket might not be setup properly
|
||||
pass
|
||||
self._netbroker.send(
|
||||
self.sock,
|
||||
self.address,
|
||||
payload,
|
||||
packet_number=packet_number,
|
||||
compressed_packet_number=compressed_packet_number,
|
||||
)
|
||||
|
||||
def recv(self, read_timeout: Optional[int] = None) -> bytearray:
|
||||
"""Get packet from the MySQL server comm channel."""
|
||||
try:
|
||||
if (
|
||||
not self._connection_timeout and self.sock is not None
|
||||
): # can't update the timeout during connection phase
|
||||
self.sock.settimeout(float(read_timeout) if read_timeout else None)
|
||||
except OSError as _:
|
||||
# Ignore the OSError as the socket might not be setup properly
|
||||
pass
|
||||
return self._netbroker.recv(self.sock, self.address)
|
||||
|
||||
@abstractmethod
|
||||
def open_connection(self) -> None:
|
||||
"""Open the socket."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def address(self) -> str:
|
||||
"""Get the location of the socket."""
|
||||
|
||||
|
||||
class MySQLUnixSocket(MySQLSocket):
|
||||
"""MySQL socket class using UNIX sockets.
|
||||
|
||||
Opens a connection through the UNIX socket of the MySQL Server.
|
||||
"""
|
||||
|
||||
def __init__(self, unix_socket: str = "/tmp/mysql.sock") -> None:
|
||||
super().__init__()
|
||||
self.unix_socket: str = unix_socket
|
||||
self._address: str = unix_socket
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
return self._address
|
||||
|
||||
def open_connection(self) -> None:
|
||||
try:
|
||||
self.sock = socket.socket(
|
||||
# pylint: disable=no-member
|
||||
socket.AF_UNIX,
|
||||
socket.SOCK_STREAM,
|
||||
)
|
||||
self.sock.settimeout(self._connection_timeout)
|
||||
self.sock.connect(self.unix_socket)
|
||||
except (socket.timeout, TimeoutError) as err:
|
||||
raise ConnectionTimeoutError(
|
||||
errno=2002,
|
||||
values=(
|
||||
self.address,
|
||||
_strioerror(err),
|
||||
),
|
||||
) from err
|
||||
except IOError as err:
|
||||
raise InterfaceError(
|
||||
errno=2002, values=(self.address, _strioerror(err))
|
||||
) from err
|
||||
except Exception as err:
|
||||
raise InterfaceError(str(err)) from err
|
||||
|
||||
def switch_to_ssl(
|
||||
self, *args: Any, **kwargs: Any # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
"""Switch the socket to use SSL."""
|
||||
warnings.warn(
|
||||
"SSL is disabled when using unix socket connections",
|
||||
Warning,
|
||||
)
|
||||
|
||||
|
||||
class MySQLTCPSocket(MySQLSocket):
|
||||
"""MySQL socket class using TCP/IP.
|
||||
|
||||
Opens a TCP/IP connection to the MySQL Server.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 3306,
|
||||
force_ipv6: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.server_host: str = host
|
||||
self.server_port: int = port
|
||||
self.force_ipv6: bool = force_ipv6
|
||||
self._family: int = 0
|
||||
self._address: str = f"{host}:{port}"
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
return self._address
|
||||
|
||||
def open_connection(self) -> None:
|
||||
"""Open the TCP/IP connection to the MySQL server."""
|
||||
# pylint: disable=no-member
|
||||
# Get address information
|
||||
addrinfo: Tuple[
|
||||
socket.AddressFamily,
|
||||
socket.SocketKind,
|
||||
int,
|
||||
str,
|
||||
Union[tuple[str, int], tuple[str, int, int, int], tuple[int, bytes]],
|
||||
] = (None, None, None, None, None)
|
||||
try:
|
||||
addrinfos = socket.getaddrinfo(
|
||||
self.server_host,
|
||||
self.server_port,
|
||||
0,
|
||||
socket.SOCK_STREAM,
|
||||
socket.SOL_TCP,
|
||||
)
|
||||
# If multiple results we favor IPv4, unless IPv6 was forced.
|
||||
for info in addrinfos:
|
||||
if self.force_ipv6 and info[0] == socket.AF_INET6:
|
||||
addrinfo = info
|
||||
break
|
||||
if info[0] == socket.AF_INET:
|
||||
addrinfo = info
|
||||
break
|
||||
if self.force_ipv6 and addrinfo[0] is None:
|
||||
raise InterfaceError(f"No IPv6 address found for {self.server_host}")
|
||||
if addrinfo[0] is None:
|
||||
addrinfo = addrinfos[0]
|
||||
except IOError as err:
|
||||
raise InterfaceError(
|
||||
errno=2003,
|
||||
values=(self.server_host, self.server_port, _strioerror(err)),
|
||||
) from err
|
||||
|
||||
(self._family, socktype, proto, _, sockaddr) = addrinfo
|
||||
|
||||
# Instantiate the socket and connect
|
||||
try:
|
||||
self.sock = socket.socket(self._family, socktype, proto)
|
||||
self.sock.settimeout(self._connection_timeout)
|
||||
self.sock.connect(sockaddr)
|
||||
except (socket.timeout, TimeoutError) as err:
|
||||
raise ConnectionTimeoutError(
|
||||
errno=2003,
|
||||
values=(
|
||||
self.server_host,
|
||||
self.server_port,
|
||||
_strioerror(err),
|
||||
),
|
||||
) from err
|
||||
except IOError as err:
|
||||
raise InterfaceError(
|
||||
errno=2003,
|
||||
values=(self.server_host, self.server_port, _strioerror(err)),
|
||||
) from err
|
||||
except Exception as err:
|
||||
raise InterfaceError(str(err)) from err
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
@@ -0,0 +1,77 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Constants used by the opentelemetry instrumentation implementation."""
|
||||
# mypy: disable-error-code="no-redef,assignment"
|
||||
|
||||
# pylint: disable=unused-import
|
||||
OTEL_ENABLED = True
|
||||
try:
|
||||
# try to load otel from the system
|
||||
from opentelemetry import trace # check api
|
||||
from opentelemetry.sdk.trace import TracerProvider # check sdk
|
||||
from opentelemetry.semconv.trace import SpanAttributes # check semconv
|
||||
except ImportError:
|
||||
OTEL_ENABLED = False
|
||||
|
||||
|
||||
OPTION_CNX_SPAN = "_span"
|
||||
"""
|
||||
Connection option name used to inject the connection span.
|
||||
This connection option name must not be used, is reserved.
|
||||
"""
|
||||
|
||||
OPTION_CNX_TRACER = "_tracer"
|
||||
"""
|
||||
Connection option name used to inject the opentelemetry tracer.
|
||||
This connection option name must not be used, is reserved.
|
||||
"""
|
||||
|
||||
CONNECTION_SPAN_NAME = "connection"
|
||||
"""
|
||||
Connection span name to be used by the instrumentor.
|
||||
"""
|
||||
|
||||
FIRST_SUPPORTED_VERSION = "8.1.0"
|
||||
"""
|
||||
First mysql-connector-python version to support opentelemetry instrumentation.
|
||||
"""
|
||||
|
||||
TRACEPARENT_HEADER_NAME = "traceparent"
|
||||
|
||||
DB_SYSTEM = "mysql"
|
||||
DEFAULT_THREAD_NAME = "main"
|
||||
DEFAULT_THREAD_ID = 0
|
||||
|
||||
# Reference: https://github.com/open-telemetry/opentelemetry-specification/blob/main/
|
||||
# specification/trace/semantic_conventions/span-general.md
|
||||
NET_SOCK_FAMILY = "net.sock.family"
|
||||
NET_SOCK_PEER_ADDR = "net.sock.peer.addr"
|
||||
NET_SOCK_PEER_PORT = "net.sock.peer.port"
|
||||
NET_SOCK_HOST_ADDR = "net.sock.host.addr"
|
||||
NET_SOCK_HOST_PORT = "net.sock.host.port"
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Trace context propagation utilities."""
|
||||
# mypy: disable-error-code="no-redef"
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from .constants import OTEL_ENABLED, TRACEPARENT_HEADER_NAME
|
||||
|
||||
if OTEL_ENABLED:
|
||||
# pylint: disable=import-error
|
||||
# load otel from the system
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.trace.span import format_span_id, format_trace_id
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..abstracts import MySQLConnectionAbstract
|
||||
|
||||
|
||||
def build_traceparent_header(span: Any) -> str:
|
||||
"""Build a traceparent header according to the provided span.
|
||||
|
||||
The context information from the provided span is used to build the traceparent
|
||||
header that will be propagated to the MySQL server. For particulars regarding
|
||||
the header creation, refer to [1].
|
||||
|
||||
This method assumes version 0 of the W3C specification.
|
||||
|
||||
Args:
|
||||
span (opentelemetry.trace.span.Span): current span in trace.
|
||||
|
||||
Returns:
|
||||
traceparent_header (str): HTTP header field that identifies requests in a
|
||||
tracing system.
|
||||
|
||||
References:
|
||||
[1]: https://www.w3.org/TR/trace-context/#traceparent-header
|
||||
"""
|
||||
# pylint: disable=possibly-used-before-assignment
|
||||
ctx = span.get_span_context()
|
||||
|
||||
version = "00" # version 0 of the W3C specification
|
||||
trace_id = format_trace_id(ctx.trace_id)
|
||||
span_id = format_span_id(ctx.span_id)
|
||||
trace_flags = "00" # sampled flag is off
|
||||
|
||||
return "-".join([version, trace_id, span_id, trace_flags])
|
||||
|
||||
|
||||
def with_context_propagation(method: Callable) -> Callable:
|
||||
"""Perform trace context propagation.
|
||||
|
||||
The trace context is propagated via query attributes. The `traceparent` header
|
||||
from W3C specification [1] is used, in this sense, the attribute name is
|
||||
`traceparent` (is RESERVED, avoid using it), and its value is built as per
|
||||
instructed in [1].
|
||||
|
||||
If opentelemetry API/SDK is unavailable or there is no recording span,
|
||||
trace context propagation is skipped.
|
||||
|
||||
References:
|
||||
[1]: https://www.w3.org/TR/trace-context/#traceparent-header
|
||||
"""
|
||||
|
||||
def wrapper(cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any) -> Any:
|
||||
"""Context propagation decorator."""
|
||||
# pylint: disable=possibly-used-before-assignment
|
||||
if not OTEL_ENABLED or not cnx.otel_context_propagation:
|
||||
return method(cnx, *args, **kwargs)
|
||||
|
||||
current_span = trace.get_current_span()
|
||||
tp_header = None
|
||||
if current_span.is_recording():
|
||||
tp_header = build_traceparent_header(current_span)
|
||||
cnx.query_attrs_append(value=(TRACEPARENT_HEADER_NAME, tp_header))
|
||||
|
||||
try:
|
||||
result = method(cnx, *args, **kwargs)
|
||||
finally:
|
||||
if tp_header is not None:
|
||||
cnx.query_attrs_remove(name=TRACEPARENT_HEADER_NAME)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,687 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""MySQL instrumentation supporting mysql-connector."""
|
||||
# mypy: disable-error-code="no-redef"
|
||||
# pylint: disable=protected-access,global-statement,invalid-name,unused-argument
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import re
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import nullcontext
|
||||
from typing import TYPE_CHECKING, Any, Callable, Collection, Dict, Optional, Union
|
||||
|
||||
# pylint: disable=cyclic-import
|
||||
if TYPE_CHECKING:
|
||||
# `TYPE_CHECKING` is always False at run time, hence circular import
|
||||
# will not happen at run time (no error happens whatsoever).
|
||||
# Since pylint is a static checker it happens that `TYPE_CHECKING`
|
||||
# is True when analyzing the code which makes pylint believe there
|
||||
# is a circular import issue when there isn't.
|
||||
|
||||
from ..abstracts import MySQLConnectionAbstract, MySQLCursorAbstract
|
||||
from ..pooling import PooledMySQLConnection
|
||||
|
||||
from ... import connector
|
||||
from ..constants import CNX_POOL_ARGS, DEFAULT_CONFIGURATION
|
||||
from ..logger import logger
|
||||
from ..version import VERSION_TEXT
|
||||
|
||||
try:
|
||||
# pylint: disable=unused-import
|
||||
# try to load otel from the system
|
||||
from opentelemetry import trace # check api
|
||||
from opentelemetry.sdk.trace import TracerProvider # check sdk
|
||||
from opentelemetry.semconv.trace import SpanAttributes # check semconv
|
||||
except ImportError as missing_dependencies_err:
|
||||
raise connector.errors.ProgrammingError(
|
||||
"OpenTelemetry installation not found. You must install the API and SDK."
|
||||
) from missing_dependencies_err
|
||||
|
||||
|
||||
from .constants import (
|
||||
CONNECTION_SPAN_NAME,
|
||||
DB_SYSTEM,
|
||||
DEFAULT_THREAD_ID,
|
||||
DEFAULT_THREAD_NAME,
|
||||
FIRST_SUPPORTED_VERSION,
|
||||
NET_SOCK_FAMILY,
|
||||
NET_SOCK_HOST_ADDR,
|
||||
NET_SOCK_HOST_PORT,
|
||||
NET_SOCK_PEER_ADDR,
|
||||
NET_SOCK_PEER_PORT,
|
||||
OPTION_CNX_SPAN,
|
||||
OPTION_CNX_TRACER,
|
||||
)
|
||||
|
||||
leading_comment_remover: re.Pattern = re.compile(r"^/\*.*?\*/")
|
||||
|
||||
|
||||
def record_exception_event(span: trace.Span, exc: Optional[Exception]) -> None:
|
||||
"""Records an exeception event."""
|
||||
if not span or not span.is_recording() or not exc:
|
||||
return
|
||||
|
||||
span.set_status(trace.Status(trace.StatusCode.ERROR))
|
||||
span.record_exception(exc)
|
||||
|
||||
|
||||
def end_span(span: trace.Span) -> None:
|
||||
"""Ends span."""
|
||||
if not span or not span.is_recording():
|
||||
return
|
||||
|
||||
span.end()
|
||||
|
||||
|
||||
def get_operation_name(operation: str) -> str:
|
||||
"""Parse query to extract operation name."""
|
||||
if operation and isinstance(operation, str):
|
||||
# Strip leading comments so we get the operation name.
|
||||
return leading_comment_remover.sub("", operation).split()[0]
|
||||
return ""
|
||||
|
||||
|
||||
def set_connection_span_attrs(
|
||||
cnx: Optional["MySQLConnectionAbstract"],
|
||||
cnx_span: trace.Span,
|
||||
cnx_kwargs: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Defines connection span attributes. If `cnx` is None then we use `cnx_kwargs`
|
||||
to get basic net information. Basic net attributes are defined such as:
|
||||
|
||||
* DB_SYSTEM
|
||||
* NET_TRANSPORT
|
||||
* NET_SOCK_FAMILY
|
||||
|
||||
Socket-level attributes [*] are also defined [**].
|
||||
|
||||
[*]: Socket-level attributes identify peer and host that are directly connected to
|
||||
each other. Since instrumentations may have limited knowledge on network
|
||||
information, instrumentations SHOULD populate such attributes to the best of
|
||||
their knowledge when populate them at all.
|
||||
|
||||
[**]: `CMySQLConnection` connections have no access to socket-level
|
||||
details so socket-level attributes aren't included. `MySQLConnection`
|
||||
connections, on the other hand, do include socket-level attributes.
|
||||
|
||||
References:
|
||||
[1]: https://github.com/open-telemetry/opentelemetry-specification/blob/main/\
|
||||
specification/trace/semantic_conventions/span-general.md
|
||||
"""
|
||||
# pylint: disable=broad-exception-caught
|
||||
if not cnx_span or not cnx_span.is_recording():
|
||||
return
|
||||
|
||||
if cnx_kwargs is None:
|
||||
cnx_kwargs = {}
|
||||
|
||||
is_tcp = not cnx._unix_socket if cnx else "unix_socket" not in cnx_kwargs
|
||||
|
||||
attrs: Dict[str, Any] = {
|
||||
SpanAttributes.DB_SYSTEM: DB_SYSTEM,
|
||||
SpanAttributes.NET_TRANSPORT: "ip_tcp" if is_tcp else "inproc",
|
||||
NET_SOCK_FAMILY: "inet" if is_tcp else "unix",
|
||||
}
|
||||
|
||||
# Only socket and tcp connections are supported.
|
||||
if is_tcp:
|
||||
attrs[SpanAttributes.NET_PEER_NAME] = (
|
||||
cnx._host if cnx else cnx_kwargs.get("host", DEFAULT_CONFIGURATION["host"])
|
||||
)
|
||||
attrs[SpanAttributes.NET_PEER_PORT] = (
|
||||
cnx._port if cnx else cnx_kwargs.get("port", DEFAULT_CONFIGURATION["port"])
|
||||
)
|
||||
|
||||
if hasattr(cnx, "_socket") and cnx._socket:
|
||||
try:
|
||||
(
|
||||
attrs[NET_SOCK_PEER_ADDR],
|
||||
sock_peer_port,
|
||||
) = cnx._socket.sock.getpeername()
|
||||
|
||||
(
|
||||
attrs[NET_SOCK_HOST_ADDR],
|
||||
attrs[NET_SOCK_HOST_PORT],
|
||||
) = cnx._socket.sock.getsockname()
|
||||
except Exception as sock_err:
|
||||
logger.warning("Connection socket is down %s", sock_err)
|
||||
else:
|
||||
if attrs[SpanAttributes.NET_PEER_PORT] != sock_peer_port:
|
||||
# NET_SOCK_PEER_PORT is recommended if different than net.peer.port
|
||||
# and if net.sock.peer.addr is set.
|
||||
attrs[NET_SOCK_PEER_PORT] = sock_peer_port
|
||||
else:
|
||||
# For Unix domain socket, net.sock.peer.addr attribute represents
|
||||
# destination name and net.peer.name SHOULD NOT be set.
|
||||
attrs[NET_SOCK_PEER_ADDR] = (
|
||||
cnx._unix_socket if cnx else cnx_kwargs.get("unix_socket")
|
||||
)
|
||||
|
||||
if hasattr(cnx, "_socket") and cnx._socket:
|
||||
try:
|
||||
attrs[NET_SOCK_HOST_ADDR] = cnx._socket.sock.getsockname()
|
||||
except Exception as sock_err:
|
||||
logger.warning("Connection socket is down %s", sock_err)
|
||||
|
||||
cnx_span.set_attributes(attrs)
|
||||
|
||||
|
||||
def with_cnx_span_attached(method: Callable) -> Callable:
|
||||
"""Attach the connection span while executing the connection method."""
|
||||
|
||||
def wrapper(cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any) -> Any:
|
||||
"""Connection span attacher decorator."""
|
||||
with trace.use_span(
|
||||
cnx._span, end_on_exit=False
|
||||
) if cnx._span and cnx._span.is_recording() else nullcontext():
|
||||
return method(cnx, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def with_cnx_query_span(method: Callable) -> Callable:
|
||||
"""Create a query span while executing the connection method."""
|
||||
|
||||
def wrapper(cnx: TracedMySQLConnection, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Query span creator decorator."""
|
||||
logger.info("Creating query span for connection.%s", method.__name__)
|
||||
|
||||
query_span_attributes: Dict = {
|
||||
SpanAttributes.DB_SYSTEM: DB_SYSTEM,
|
||||
SpanAttributes.DB_USER: cnx._user,
|
||||
SpanAttributes.THREAD_ID: DEFAULT_THREAD_ID,
|
||||
SpanAttributes.THREAD_NAME: DEFAULT_THREAD_NAME,
|
||||
"connection_type": cnx.get_wrapped_class(),
|
||||
}
|
||||
|
||||
with cnx._tracer.start_as_current_span(
|
||||
name=method.__name__.upper(),
|
||||
kind=trace.SpanKind.CLIENT,
|
||||
links=[trace.Link(cnx._span.get_span_context())],
|
||||
attributes=query_span_attributes,
|
||||
) if cnx._span and cnx._span.is_recording() else nullcontext():
|
||||
return method(cnx, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def with_cursor_query_span(method: Callable) -> Callable:
|
||||
"""Create a query span while executing the cursor method."""
|
||||
|
||||
def wrapper(cur: TracedMySQLCursor, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Query span creator decorator."""
|
||||
logger.info("Creating query span for cursor.%s", method.__name__)
|
||||
|
||||
connection: "MySQLConnectionAbstract" = (
|
||||
getattr(cur._wrapped, "_connection")
|
||||
if hasattr(cur._wrapped, "_connection")
|
||||
else getattr(cur._wrapped, "_cnx")
|
||||
)
|
||||
|
||||
query_span_attributes: Dict = {
|
||||
SpanAttributes.DB_SYSTEM: DB_SYSTEM,
|
||||
SpanAttributes.DB_USER: connection._user,
|
||||
SpanAttributes.THREAD_ID: DEFAULT_THREAD_ID,
|
||||
SpanAttributes.THREAD_NAME: DEFAULT_THREAD_NAME,
|
||||
"cursor_type": cur.get_wrapped_class(),
|
||||
}
|
||||
|
||||
with cur._tracer.start_as_current_span(
|
||||
name=get_operation_name(args[0]) or "SQL statement",
|
||||
kind=trace.SpanKind.CLIENT,
|
||||
links=[cur._connection_span_link],
|
||||
attributes=query_span_attributes,
|
||||
):
|
||||
return method(cur, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class BaseMySQLTracer(ABC):
|
||||
"""Base class that provides basic object wrapper functionality."""
|
||||
|
||||
@abstractmethod
|
||||
def __init__(self) -> None:
|
||||
"""Must be implemented by subclasses."""
|
||||
|
||||
def __getattr__(self, attr: str) -> Any:
|
||||
"""Gets an attribute.
|
||||
|
||||
Attributes defined in the wrapper object have higher precedence
|
||||
than those wrapped object equivalent. Attributes not found in
|
||||
the wrapper are then searched in the wrapped object.
|
||||
"""
|
||||
if attr in self.__dict__:
|
||||
# this object has it
|
||||
return getattr(self, attr)
|
||||
# proxy to the wrapped object
|
||||
return getattr(self._wrapped, attr)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
if "_wrapped" not in self.__dict__:
|
||||
self.__dict__["_wrapped"] = value
|
||||
return
|
||||
|
||||
if name in self.__dict__ or name == "autocommit":
|
||||
# this object has it
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
# proxy to the wrapped object
|
||||
self._wrapped.__setattr__(name, value)
|
||||
|
||||
def __enter__(self) -> Any:
|
||||
"""Magic method."""
|
||||
self._wrapped.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Magic method."""
|
||||
self._wrapped.__exit__(*args, **kwargs)
|
||||
|
||||
def get_wrapped_class(self) -> str:
|
||||
"""Gets the wrapped class name."""
|
||||
return self._wrapped.__class__.__name__
|
||||
|
||||
|
||||
class TracedMySQLCursor(BaseMySQLTracer):
|
||||
"""Wrapper class for a `MySQLCursor` or `CMySQLCursor` object."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
wrapped: "MySQLCursorAbstract",
|
||||
tracer: trace.Tracer,
|
||||
connection_span: trace.Span,
|
||||
):
|
||||
"""Constructor."""
|
||||
self._wrapped: "MySQLCursorAbstract" = wrapped
|
||||
self._tracer: trace.Tracer = tracer
|
||||
self._connection_span_link: trace.Link = trace.Link(
|
||||
connection_span.get_span_context()
|
||||
)
|
||||
|
||||
@with_cursor_query_span
|
||||
def execute(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.execute(*args, **kwargs)
|
||||
|
||||
@with_cursor_query_span
|
||||
def executemany(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.executemany(*args, **kwargs)
|
||||
|
||||
@with_cursor_query_span
|
||||
def callproc(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.callproc(*args, **kwargs)
|
||||
|
||||
|
||||
class TracedMySQLConnection(BaseMySQLTracer):
|
||||
"""Wrapper class for a `MySQLConnection` or `CMySQLConnection` object."""
|
||||
|
||||
def __init__(self, wrapped: "MySQLConnectionAbstract") -> None:
|
||||
"""Constructor."""
|
||||
self._wrapped: "MySQLConnectionAbstract" = wrapped
|
||||
|
||||
# call `sql_mode` so its value is cached internally and querying it does not
|
||||
# interfere when recording query span events later.
|
||||
_ = self._wrapped.sql_mode
|
||||
|
||||
def cursor(self, *args: Any, **kwargs: Any) -> TracedMySQLCursor:
|
||||
"""Wraps the object method."""
|
||||
return TracedMySQLCursor(
|
||||
wrapped=self._wrapped.cursor(*args, **kwargs),
|
||||
tracer=self._tracer,
|
||||
connection_span=self._span,
|
||||
)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_change_user(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_change_user(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def commit(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.commit(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def rollback(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.rollback(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_query(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_query(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_init_db(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_init_db(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_refresh(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_refresh(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_quit(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_quit(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_shutdown(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_shutdown(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_statistics(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_statistics(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_process_kill(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_process_kill(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_debug(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_debug(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_ping(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_ping(*args, **kwargs)
|
||||
|
||||
@property
|
||||
@with_cnx_query_span
|
||||
def database(self) -> str:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.database
|
||||
|
||||
@with_cnx_query_span
|
||||
def is_connected(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.is_connected(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def reset_session(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.reset_session(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def ping(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.ping(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def info_query(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.info_query(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_stmt_prepare(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_stmt_prepare(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_stmt_execute(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_stmt_execute(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_stmt_close(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_stmt_close(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_stmt_send_long_data(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_stmt_send_long_data(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_stmt_reset(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_stmt_reset(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def cmd_reset_connection(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.cmd_reset_connection(*args, **kwargs)
|
||||
|
||||
@property
|
||||
@with_cnx_query_span
|
||||
def time_zone(self) -> str:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.time_zone
|
||||
|
||||
@property
|
||||
@with_cnx_query_span
|
||||
def sql_mode(self) -> str:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.sql_mode
|
||||
|
||||
@property
|
||||
@with_cnx_query_span
|
||||
def autocommit(self) -> bool:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.autocommit
|
||||
|
||||
@autocommit.setter
|
||||
@with_cnx_query_span
|
||||
def autocommit(self, value: bool) -> None:
|
||||
"""Instrument method."""
|
||||
self._wrapped.autocommit = value
|
||||
|
||||
@with_cnx_query_span
|
||||
def set_charset_collation(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.set_charset_collation(*args, **kwargs)
|
||||
|
||||
@with_cnx_query_span
|
||||
def start_transaction(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Instrument method."""
|
||||
return self._wrapped.start_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
def _instrument_connect(
|
||||
connect: Callable[..., Union["MySQLConnectionAbstract", "PooledMySQLConnection"]],
|
||||
tracer_provider: Optional[trace.TracerProvider] = None,
|
||||
) -> Callable[..., Union["MySQLConnectionAbstract", "PooledMySQLConnection"]]:
|
||||
"""Retrurn the instrumented version of `connect`."""
|
||||
|
||||
# let's preserve `connect` identity.
|
||||
@functools.wraps(connect)
|
||||
def wrapper(
|
||||
*args: Any, **kwargs: Any
|
||||
) -> Union["MySQLConnectionAbstract", "PooledMySQLConnection"]:
|
||||
"""Wraps the connection object returned by the method `connect`.
|
||||
|
||||
Instrumentation for PooledConnections is not supported.
|
||||
"""
|
||||
if any(key in kwargs for key in CNX_POOL_ARGS):
|
||||
logger.warning("Instrumentation for pooled connections not supported")
|
||||
return connect(*args, **kwargs)
|
||||
|
||||
tracer = trace.get_tracer(
|
||||
instrumenting_module_name="MySQL Connector/Python",
|
||||
instrumenting_library_version=VERSION_TEXT,
|
||||
tracer_provider=tracer_provider,
|
||||
)
|
||||
|
||||
# The connection span is passed in as an argument so the connection object can
|
||||
# keep a pointer to it.
|
||||
kwargs[OPTION_CNX_SPAN] = tracer.start_span(
|
||||
name=CONNECTION_SPAN_NAME, kind=trace.SpanKind.CLIENT
|
||||
)
|
||||
kwargs[OPTION_CNX_TRACER] = tracer
|
||||
|
||||
# attach connection span
|
||||
# pylint: disable=not-context-manager
|
||||
with trace.use_span(kwargs[OPTION_CNX_SPAN], end_on_exit=False) as cnx_span:
|
||||
# Add basic net information.
|
||||
set_connection_span_attrs(None, cnx_span, kwargs)
|
||||
|
||||
# Connection may fail at this point, in case it does, basic net info is already
|
||||
# included so the user can check the net configuration she/he provided.
|
||||
cnx = connect(*args, **kwargs)
|
||||
|
||||
# connection went ok, let's refine the net information.
|
||||
set_connection_span_attrs(cnx, cnx_span, kwargs) # type: ignore[arg-type]
|
||||
|
||||
return TracedMySQLConnection(
|
||||
wrapped=cnx, # type: ignore[return-value, arg-type]
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class MySQLInstrumentor:
|
||||
"""MySQL instrumentation supporting mysql-connector-python."""
|
||||
|
||||
_instance: Optional[MySQLInstrumentor] = None
|
||||
|
||||
def __new__(cls, *args: Any, **kwargs: Any) -> MySQLInstrumentor:
|
||||
"""Singlenton.
|
||||
|
||||
Restricts the instantiation to a singular instance.
|
||||
"""
|
||||
if cls._instance is None:
|
||||
# create instance
|
||||
cls._instance = object.__new__(cls, *args, **kwargs)
|
||||
# keep a pointer to the uninstrumented connect method
|
||||
setattr(cls._instance, "_original_connect", connector.connect)
|
||||
return cls._instance
|
||||
|
||||
def instrumentation_dependencies(self) -> Collection[str]:
|
||||
"""Return a list of python packages with versions
|
||||
that the will be instrumented (e.g., versions >= 8.1.0)."""
|
||||
return [f"mysql-connector-python >= {FIRST_SUPPORTED_VERSION}"]
|
||||
|
||||
def instrument(self, **kwargs: Any) -> None:
|
||||
"""Instrument the library.
|
||||
|
||||
Args:
|
||||
trace_module: reference to the 'trace' module from opentelemetry.
|
||||
tracer_provider (optional): TracerProvider instance.
|
||||
|
||||
NOTE: Instrumentation for pooled connections not supported.
|
||||
"""
|
||||
if connector.connect != getattr(self, "_original_connect"):
|
||||
logger.warning("MySQL Connector/Python module already instrumented.")
|
||||
return
|
||||
connector.connect = _instrument_connect(
|
||||
connect=getattr(self, "_original_connect"),
|
||||
tracer_provider=kwargs.get("tracer_provider"),
|
||||
)
|
||||
|
||||
def instrument_connection(
|
||||
self,
|
||||
connection: "MySQLConnectionAbstract",
|
||||
tracer_provider: Optional[trace.TracerProvider] = None,
|
||||
) -> "MySQLConnectionAbstract":
|
||||
"""Enable instrumentation in a MySQL connection.
|
||||
|
||||
Args:
|
||||
connection: uninstrumented connection instance.
|
||||
trace_module: reference to the 'trace' module from opentelemetry.
|
||||
tracer_provider (optional): TracerProvider instance.
|
||||
|
||||
Returns:
|
||||
connection: instrumented connection instace.
|
||||
|
||||
NOTE: Instrumentation for pooled connections not supported.
|
||||
"""
|
||||
if isinstance(connection, TracedMySQLConnection):
|
||||
logger.warning("Connection already instrumented.")
|
||||
return connection
|
||||
|
||||
if not hasattr(connection, "_span") or not hasattr(connection, "_tracer"):
|
||||
logger.warning(
|
||||
"Instrumentation for class %s not supported.",
|
||||
connection.__class__.__name__,
|
||||
)
|
||||
return connection
|
||||
|
||||
tracer = trace.get_tracer(
|
||||
instrumenting_module_name="MySQL Connector/Python",
|
||||
instrumenting_library_version=VERSION_TEXT,
|
||||
tracer_provider=tracer_provider,
|
||||
)
|
||||
connection._span = tracer.start_span(
|
||||
name=CONNECTION_SPAN_NAME, kind=trace.SpanKind.CLIENT
|
||||
)
|
||||
connection._tracer = tracer
|
||||
|
||||
set_connection_span_attrs(connection, connection._span)
|
||||
|
||||
return TracedMySQLConnection(wrapped=connection) # type: ignore[return-value]
|
||||
|
||||
def uninstrument(self, **kwargs: Any) -> None:
|
||||
"""Uninstrument the library."""
|
||||
# pylint: disable=unused-argument
|
||||
if connector.connect == getattr(self, "_original_connect"):
|
||||
logger.warning("MySQL Connector/Python module already uninstrumented.")
|
||||
return
|
||||
connector.connect = getattr(self, "_original_connect")
|
||||
|
||||
def uninstrument_connection(
|
||||
self, connection: "MySQLConnectionAbstract"
|
||||
) -> "MySQLConnectionAbstract":
|
||||
"""Disable instrumentation in a MySQL connection.
|
||||
|
||||
Args:
|
||||
connection: instrumented connection instance.
|
||||
|
||||
Returns:
|
||||
connection: uninstrumented connection instace.
|
||||
|
||||
NOTE: Instrumentation for pooled connections not supported.
|
||||
"""
|
||||
if not hasattr(connection, "_span"):
|
||||
logger.warning(
|
||||
"Uninstrumentation for class %s not supported.",
|
||||
connection.__class__.__name__,
|
||||
)
|
||||
return connection
|
||||
|
||||
if not isinstance(connection, TracedMySQLConnection):
|
||||
logger.warning("Connection already uninstrumented.")
|
||||
return connection
|
||||
|
||||
# stop connection span recording
|
||||
if connection._span and connection._span.is_recording():
|
||||
connection._span.end()
|
||||
connection._span = None
|
||||
|
||||
return connection._wrapped
|
||||
@@ -0,0 +1,367 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="attr-defined"
|
||||
|
||||
"""Implements parser to parse MySQL option files."""
|
||||
|
||||
import ast
|
||||
import codecs
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
|
||||
from configparser import ConfigParser as SafeConfigParser, MissingSectionHeaderError
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from .constants import CNX_POOL_ARGS, DEFAULT_CONFIGURATION
|
||||
|
||||
DEFAULT_EXTENSIONS: Dict[str, Tuple[str, ...]] = {
|
||||
"nt": ("ini", "cnf"),
|
||||
"posix": ("cnf",),
|
||||
}
|
||||
|
||||
|
||||
def read_option_files(**config: Union[str, List[str]]) -> Dict[str, Any]:
|
||||
"""
|
||||
Read option files for connection parameters.
|
||||
|
||||
Checks if connection arguments contain option file arguments, and then
|
||||
reads option files accordingly.
|
||||
"""
|
||||
if "option_files" in config:
|
||||
try:
|
||||
if isinstance(config["option_groups"], str):
|
||||
config["option_groups"] = [config["option_groups"]]
|
||||
groups = config["option_groups"]
|
||||
del config["option_groups"]
|
||||
except KeyError:
|
||||
groups = ["client", "connector_python"]
|
||||
|
||||
if isinstance(config["option_files"], str):
|
||||
config["option_files"] = [config["option_files"]]
|
||||
option_parser = MySQLOptionsParser(
|
||||
list(config["option_files"]), keep_dashes=False
|
||||
)
|
||||
del config["option_files"]
|
||||
|
||||
config_from_file: Dict[str, Any] = (
|
||||
option_parser.get_groups_as_dict_with_priority(*groups)
|
||||
)
|
||||
config_options: Dict[str, Tuple[str, int, str]] = {}
|
||||
for group in groups:
|
||||
try:
|
||||
for option, value in config_from_file[group].items():
|
||||
value += (group,)
|
||||
try:
|
||||
if option == "socket":
|
||||
option = "unix_socket"
|
||||
option_parser.set(group, "unix_socket", value[0])
|
||||
|
||||
if option not in CNX_POOL_ARGS and option != "failover":
|
||||
_ = DEFAULT_CONFIGURATION[option]
|
||||
|
||||
if (
|
||||
option not in config_options
|
||||
or config_options[option][1] <= value[1]
|
||||
):
|
||||
config_options[option] = value
|
||||
except KeyError:
|
||||
if group == "connector_python":
|
||||
raise AttributeError(
|
||||
f"Unsupported argument '{option}'"
|
||||
) from None
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
for option, values in config_options.items():
|
||||
value, _, section = values
|
||||
if (
|
||||
option not in config
|
||||
and option_parser.has_section(section)
|
||||
and option_parser.has_option(section, option)
|
||||
):
|
||||
if option in ("password", "passwd"): # keep the value as string
|
||||
config[option] = str(value)
|
||||
else:
|
||||
try:
|
||||
config[option] = ast.literal_eval(value)
|
||||
except (ValueError, TypeError, SyntaxError):
|
||||
config[option] = value
|
||||
if "socket" in config:
|
||||
config["unix_socket"] = config.pop("socket")
|
||||
return config
|
||||
|
||||
|
||||
class MySQLOptionsParser(SafeConfigParser):
|
||||
"""This class implements methods to parse MySQL option files"""
|
||||
|
||||
def __init__(
|
||||
self, files: Optional[Union[List[str], str]] = None, keep_dashes: bool = True
|
||||
) -> None:
|
||||
"""Initialize
|
||||
|
||||
If defaults is True, default option files are read first
|
||||
|
||||
Raises ValueError if defaults is set to True but defaults files
|
||||
cannot be found.
|
||||
"""
|
||||
|
||||
# Regular expression to allow options with no value(For Python v2.6)
|
||||
self.optcre: re.Pattern = re.compile(
|
||||
r"(?P<option>[^:=\s][^:=]*)"
|
||||
r"\s*(?:"
|
||||
r"(?P<vi>[:=])\s*"
|
||||
r"(?P<value>.*))?$"
|
||||
)
|
||||
|
||||
self._options_dict: Dict[str, Dict[str, Tuple[str, int]]] = {}
|
||||
|
||||
SafeConfigParser.__init__(self, strict=False)
|
||||
|
||||
self.default_extension: Tuple[str, ...] = DEFAULT_EXTENSIONS[os.name]
|
||||
self.keep_dashes: bool = keep_dashes
|
||||
|
||||
if not files:
|
||||
raise ValueError("files argument should be given")
|
||||
self.files: List[str] = [files] if isinstance(files, str) else files
|
||||
|
||||
self._parse_options(list(self.files))
|
||||
self._sections: Dict[str, Dict[str, str]] = self.get_groups_as_dict()
|
||||
|
||||
def optionxform(self, optionstr: str) -> str:
|
||||
"""Converts option strings
|
||||
|
||||
Converts option strings to lower case and replaces dashes(-) with
|
||||
underscores(_) if keep_dashes variable is set.
|
||||
"""
|
||||
if not self.keep_dashes:
|
||||
optionstr = optionstr.replace("-", "_")
|
||||
return optionstr.lower()
|
||||
|
||||
def _parse_options(self, files: List[str]) -> None:
|
||||
"""Parse options from files given as arguments.
|
||||
This method checks for !include or !inculdedir directives and if there
|
||||
is any, those files included by these directives are also parsed
|
||||
for options.
|
||||
|
||||
Raises ValueError if any of the included or file given in arguments
|
||||
is not readable.
|
||||
"""
|
||||
initial_files = files[:]
|
||||
files = []
|
||||
index = 0
|
||||
err_msg = "Option file '{0}' being included again in file '{1}'"
|
||||
|
||||
for file_ in initial_files:
|
||||
try:
|
||||
if file_ in initial_files[index + 1 :]:
|
||||
raise ValueError(
|
||||
f"Same option file '{file_}' occurring more "
|
||||
"than once in the list"
|
||||
)
|
||||
with open(file_, "r", encoding="utf-8") as op_file:
|
||||
for line in op_file.readlines():
|
||||
if line.startswith("!includedir"):
|
||||
_, dir_path = line.split(None, 1)
|
||||
dir_path = dir_path.strip()
|
||||
for entry in os.listdir(dir_path):
|
||||
entry = os.path.join(dir_path, entry)
|
||||
if entry in files:
|
||||
raise ValueError(err_msg.format(entry, file_))
|
||||
if os.path.isfile(entry) and entry.endswith(
|
||||
self.default_extension
|
||||
):
|
||||
files.append(entry)
|
||||
|
||||
elif line.startswith("!include"):
|
||||
_, filename = line.split(None, 1)
|
||||
filename = filename.strip()
|
||||
if filename in files:
|
||||
raise ValueError(err_msg.format(filename, file_))
|
||||
files.append(filename)
|
||||
|
||||
index += 1
|
||||
files.append(file_)
|
||||
except IOError as err:
|
||||
raise ValueError(f"Failed reading file '{file_}': {err}") from err
|
||||
|
||||
read_files = self.read(files)
|
||||
not_read_files = set(files) - set(read_files)
|
||||
if not_read_files:
|
||||
raise ValueError(f"File(s) {', '.join(not_read_files)} could not be read.")
|
||||
|
||||
def read( # type: ignore[override]
|
||||
self, filenames: Union[str, List[str]], encoding: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""Read and parse a filename or a list of filenames.
|
||||
|
||||
Overridden from ConfigParser and modified so as to allow options
|
||||
which are not inside any section header
|
||||
|
||||
Return list of successfully read files.
|
||||
"""
|
||||
if isinstance(filenames, str):
|
||||
filenames = [filenames]
|
||||
read_ok = []
|
||||
for priority, filename in enumerate(filenames):
|
||||
try:
|
||||
out_file = io.StringIO()
|
||||
with codecs.open(filename, encoding="utf-8") as in_file:
|
||||
for line in in_file:
|
||||
line = line.strip()
|
||||
# Skip lines that begin with "!includedir" or "!include"
|
||||
if line.startswith("!include"):
|
||||
continue
|
||||
|
||||
match_obj = self.optcre.match(line)
|
||||
if not self.SECTCRE.match(line) and match_obj:
|
||||
optname, delimiter, optval = match_obj.group(
|
||||
"option", "vi", "value"
|
||||
)
|
||||
if optname and not optval and not delimiter:
|
||||
out_file.write(f"{line}=\n")
|
||||
else:
|
||||
out_file.write(f"{line}\n")
|
||||
else:
|
||||
out_file.write(f"{line}\n")
|
||||
out_file.seek(0)
|
||||
except IOError:
|
||||
continue
|
||||
try:
|
||||
self._read(out_file, filename)
|
||||
for group in self._sections.keys():
|
||||
try:
|
||||
self._options_dict[group]
|
||||
except KeyError:
|
||||
self._options_dict[group] = {}
|
||||
for option, value in self._sections[group].items():
|
||||
self._options_dict[group][option] = (value, priority)
|
||||
|
||||
self._sections = self._dict()
|
||||
|
||||
except MissingSectionHeaderError:
|
||||
self._read(out_file, filename)
|
||||
out_file.close()
|
||||
read_ok.append(filename)
|
||||
return read_ok
|
||||
|
||||
def get_groups(self, *args: str) -> Dict[str, str]:
|
||||
"""Returns options as a dictionary.
|
||||
|
||||
Returns options from all the groups specified as arguments, returns
|
||||
the options from all groups if no argument provided. Options are
|
||||
overridden when they are found in the next group.
|
||||
|
||||
Returns a dictionary
|
||||
"""
|
||||
if not args:
|
||||
args = tuple(self._options_dict.keys())
|
||||
|
||||
options = {}
|
||||
priority: Dict[str, int] = {}
|
||||
for group in args:
|
||||
try:
|
||||
for option, value in [
|
||||
(
|
||||
key,
|
||||
value,
|
||||
)
|
||||
for key, value in self._options_dict[group].items()
|
||||
if key != "__name__" and not key.startswith("!")
|
||||
]:
|
||||
if option not in options or priority[option] <= value[1]:
|
||||
priority[option] = value[1]
|
||||
options[option] = value[0]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return options
|
||||
|
||||
def get_groups_as_dict_with_priority(
|
||||
self, *args: str
|
||||
) -> Dict[str, Dict[str, Tuple[str, int]]]:
|
||||
"""Returns options as dictionary of dictionaries.
|
||||
|
||||
Returns options from all the groups specified as arguments. For each
|
||||
group the option are contained in a dictionary. The order in which
|
||||
the groups are specified is unimportant. Also options are not
|
||||
overridden in between the groups.
|
||||
|
||||
The value is a tuple with two elements, first being the actual value
|
||||
and second is the priority of the value which is higher for a value
|
||||
read from a higher priority file.
|
||||
|
||||
Returns an dictionary of dictionaries
|
||||
"""
|
||||
if not args:
|
||||
args = tuple(self._options_dict.keys())
|
||||
|
||||
options = {}
|
||||
for group in args:
|
||||
try:
|
||||
options[group] = dict(
|
||||
(
|
||||
key,
|
||||
value,
|
||||
)
|
||||
for key, value in self._options_dict[group].items()
|
||||
if key != "__name__" and not key.startswith("!")
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
return options
|
||||
|
||||
def get_groups_as_dict(self, *args: str) -> Dict[str, Dict[str, str]]:
|
||||
"""Returns options as dictionary of dictionaries.
|
||||
|
||||
Returns options from all the groups specified as arguments. For each
|
||||
group the option are contained in a dictionary. The order in which
|
||||
the groups are specified is unimportant. Also options are not
|
||||
overridden in between the groups.
|
||||
|
||||
Returns an dictionary of dictionaries
|
||||
"""
|
||||
if not args:
|
||||
args = tuple(self._options_dict.keys())
|
||||
|
||||
options = {}
|
||||
for group in args:
|
||||
try:
|
||||
options[group] = dict(
|
||||
(
|
||||
key,
|
||||
value[0],
|
||||
)
|
||||
for key, value in self._options_dict[group].items()
|
||||
if key != "__name__" and not key.startswith("!")
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return options
|
||||
@@ -0,0 +1,160 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Base Authentication Plugin class."""
|
||||
|
||||
import importlib
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING, Any, Optional, Type
|
||||
|
||||
from ..errors import NotSupportedError, ProgrammingError
|
||||
from ..logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
DEFAULT_PLUGINS_PKG = "mysql.connector.plugins"
|
||||
|
||||
|
||||
class MySQLAuthPlugin(ABC):
|
||||
"""Authorization plugin interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
ssl_enabled: bool = False,
|
||||
) -> None:
|
||||
"""Constructor."""
|
||||
self._username: str = "" if username is None else username
|
||||
self._password: str = "" if password is None else password
|
||||
self._ssl_enabled: bool = ssl_enabled
|
||||
|
||||
@property
|
||||
def ssl_enabled(self) -> bool:
|
||||
"""Signals whether or not SSL is enabled."""
|
||||
return self._ssl_enabled
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
|
||||
@abstractmethod
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Make the client's authorization response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
"""
|
||||
|
||||
def auth_more_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth more data` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Authentication method data (from a packet representing
|
||||
an `auth more data` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth communication.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth communication.
|
||||
"""
|
||||
|
||||
|
||||
@lru_cache(maxsize=10, typed=False)
|
||||
def get_auth_plugin(
|
||||
plugin_name: str,
|
||||
auth_plugin_class: Optional[str] = None,
|
||||
) -> Type[MySQLAuthPlugin]:
|
||||
"""Return authentication class based on plugin name
|
||||
|
||||
This function returns the class for the authentication plugin plugin_name.
|
||||
The returned class is a subclass of BaseAuthPlugin.
|
||||
|
||||
Args:
|
||||
plugin_name (str): Authentication plugin name.
|
||||
auth_plugin_class (str): Authentication plugin class name.
|
||||
|
||||
Raises:
|
||||
NotSupportedError: When plugin_name is not supported.
|
||||
|
||||
Returns:
|
||||
Subclass of `MySQLAuthPlugin`.
|
||||
"""
|
||||
package = DEFAULT_PLUGINS_PKG
|
||||
if plugin_name:
|
||||
try:
|
||||
logger.info("package: %s", package)
|
||||
logger.info("plugin_name: %s", plugin_name)
|
||||
plugin_module = importlib.import_module(f".{plugin_name}", package)
|
||||
if not auth_plugin_class or not hasattr(plugin_module, auth_plugin_class):
|
||||
auth_plugin_class = plugin_module.AUTHENTICATION_PLUGIN_CLASS
|
||||
logger.info("AUTHENTICATION_PLUGIN_CLASS: %s", auth_plugin_class)
|
||||
return getattr(plugin_module, auth_plugin_class)
|
||||
except ModuleNotFoundError as err:
|
||||
logger.warning("Requested Module was not found: %s", err)
|
||||
except ValueError as err:
|
||||
raise ProgrammingError(f"Invalid module name: {err}") from err
|
||||
raise NotSupportedError(f"Authentication plugin '{plugin_name}' is not supported")
|
||||
+576
@@ -0,0 +1,576 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="str-bytes-safe,misc"
|
||||
|
||||
"""Kerberos Authentication Plugin."""
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import struct
|
||||
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Optional, Tuple
|
||||
|
||||
from ..authentication import ERR_STATUS
|
||||
from ..errors import InterfaceError, ProgrammingError
|
||||
from ..logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
import gssapi
|
||||
except ImportError:
|
||||
gssapi = None
|
||||
if os.name != "nt":
|
||||
raise ProgrammingError(
|
||||
"Module gssapi is required for GSSAPI authentication "
|
||||
"mechanism but was not found. Unable to authenticate "
|
||||
"with the server"
|
||||
) from None
|
||||
|
||||
try:
|
||||
import sspi
|
||||
import sspicon
|
||||
except ImportError:
|
||||
sspi = None
|
||||
sspicon = None
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = (
|
||||
"MySQLSSPIKerberosAuthPlugin" if os.name == "nt" else "MySQLKerberosAuthPlugin"
|
||||
)
|
||||
|
||||
|
||||
class MySQLBaseKerberosAuthPlugin(MySQLAuthPlugin):
|
||||
"""Base class for the MySQL Kerberos authentication plugin."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_kerberos_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def auth_continue(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge: the challenge for the negotiation.
|
||||
|
||||
Returns:
|
||||
tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
logger.debug("# auth_data: %s", auth_data)
|
||||
response = self.auth_response(auth_data, ignore_auth_data=False, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
packet = sock.recv()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
if packet != ERR_STATUS:
|
||||
rcode_size = 5 # Reader size for the response status code
|
||||
logger.debug("# Continue with GSSAPI authentication")
|
||||
logger.debug("# Response header: %s", packet[: rcode_size + 1])
|
||||
logger.debug("# Response size: %s", len(packet))
|
||||
logger.debug("# Negotiate a service request")
|
||||
complete = False
|
||||
tries = 0
|
||||
|
||||
while not complete and tries < 5:
|
||||
logger.debug("%s Attempt %s %s", "-" * 20, tries + 1, "-" * 20)
|
||||
logger.debug("<< Server response: %s", packet)
|
||||
logger.debug("# Response code: %s", packet[: rcode_size + 1])
|
||||
token, complete = self.auth_continue(packet[rcode_size:])
|
||||
if token:
|
||||
sock.send(token)
|
||||
if complete:
|
||||
break
|
||||
packet = sock.recv()
|
||||
|
||||
logger.debug(">> Response to server: %s", token)
|
||||
tries += 1
|
||||
|
||||
if not complete:
|
||||
raise InterfaceError(
|
||||
f"Unable to fulfill server request after {tries} "
|
||||
f"attempts. Last server response: {packet}"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Last response from server: %s length: %d",
|
||||
packet,
|
||||
len(packet),
|
||||
)
|
||||
|
||||
# Receive OK packet from server.
|
||||
packet = sock.recv()
|
||||
logger.debug("<< Ok packet from server: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
|
||||
|
||||
# pylint: disable=c-extension-no-member,no-member
|
||||
class MySQLKerberosAuthPlugin(MySQLBaseKerberosAuthPlugin):
|
||||
"""Implement the MySQL Kerberos authentication plugin."""
|
||||
|
||||
context: Optional[gssapi.SecurityContext] = None
|
||||
|
||||
@staticmethod
|
||||
def get_user_from_credentials() -> str:
|
||||
"""Get user from credentials without realm."""
|
||||
try:
|
||||
creds = gssapi.Credentials(usage="initiate")
|
||||
user = str(creds.name)
|
||||
if user.find("@") != -1:
|
||||
user, _ = user.split("@", 1)
|
||||
return user
|
||||
except gssapi.raw.misc.GSSError:
|
||||
return getpass.getuser()
|
||||
|
||||
@staticmethod
|
||||
def get_store() -> dict:
|
||||
"""Get a credentials store dictionary.
|
||||
|
||||
Returns:
|
||||
dict: Credentials store dictionary with the krb5 ccache name.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If 'KRB5CCNAME' environment variable is empty.
|
||||
"""
|
||||
krb5ccname = os.environ.get(
|
||||
"KRB5CCNAME",
|
||||
(
|
||||
f"/tmp/krb5cc_{os.getuid()}"
|
||||
if os.name == "posix"
|
||||
else Path("%TEMP%").joinpath("krb5cc")
|
||||
),
|
||||
)
|
||||
if not krb5ccname:
|
||||
raise InterfaceError(
|
||||
"The 'KRB5CCNAME' environment variable is set to empty"
|
||||
)
|
||||
logger.debug("Using krb5 ccache name: FILE:%s", krb5ccname)
|
||||
store = {b"ccache": f"FILE:{krb5ccname}".encode("utf-8")}
|
||||
return store
|
||||
|
||||
def _acquire_cred_with_password(self, upn: str) -> gssapi.raw.creds.Creds:
|
||||
"""Acquire and store credentials through provided password.
|
||||
|
||||
Args:
|
||||
upn (str): User Principal Name.
|
||||
|
||||
Returns:
|
||||
gssapi.raw.creds.Creds: GSSAPI credentials.
|
||||
"""
|
||||
logger.debug("Attempt to acquire credentials through provided password")
|
||||
user = gssapi.Name(upn, gssapi.NameType.user)
|
||||
password = self._password.encode("utf-8")
|
||||
|
||||
try:
|
||||
acquire_cred_result = gssapi.raw.acquire_cred_with_password(
|
||||
user, password, usage="initiate"
|
||||
)
|
||||
creds = acquire_cred_result.creds
|
||||
gssapi.raw.store_cred_into(
|
||||
self.get_store(),
|
||||
creds=creds,
|
||||
mech=gssapi.MechType.kerberos,
|
||||
overwrite=True,
|
||||
set_default=True,
|
||||
)
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
raise ProgrammingError(
|
||||
f"Unable to acquire credentials with the given password: {err}"
|
||||
) from err
|
||||
return creds
|
||||
|
||||
@staticmethod
|
||||
def _parse_auth_data(packet: bytes) -> Tuple[str, str]:
|
||||
"""Parse authentication data.
|
||||
|
||||
Get the SPN and REALM from the authentication data packet.
|
||||
|
||||
Format:
|
||||
SPN string length two bytes <B1> <B2> +
|
||||
SPN string +
|
||||
UPN realm string length two bytes <B1> <B2> +
|
||||
UPN realm string
|
||||
|
||||
Returns:
|
||||
tuple: With 'spn' and 'realm'.
|
||||
"""
|
||||
spn_len = struct.unpack("<H", packet[:2])[0]
|
||||
packet = packet[2:]
|
||||
|
||||
spn = struct.unpack(f"<{spn_len}s", packet[:spn_len])[0]
|
||||
packet = packet[spn_len:]
|
||||
|
||||
realm_len = struct.unpack("<H", packet[:2])[0]
|
||||
realm = struct.unpack(f"<{realm_len}s", packet[2:])[0]
|
||||
|
||||
return spn.decode(), realm.decode()
|
||||
|
||||
def auth_response(
|
||||
self, auth_data: Optional[bytes] = None, **kwargs: Any
|
||||
) -> Optional[bytes]:
|
||||
"""Prepare the first message to the server."""
|
||||
spn = None
|
||||
realm = None
|
||||
|
||||
if auth_data and not kwargs.get("ignore_auth_data", True):
|
||||
try:
|
||||
spn, realm = self._parse_auth_data(auth_data)
|
||||
except struct.error as err:
|
||||
raise InterruptedError(f"Invalid authentication data: {err}") from err
|
||||
|
||||
if spn is None:
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
upn = f"{self._username}@{realm}" if self._username else None
|
||||
|
||||
logger.debug("Service Principal: %s", spn)
|
||||
logger.debug("Realm: %s", realm)
|
||||
|
||||
try:
|
||||
# Attempt to retrieve credentials from cache file
|
||||
creds: Any = gssapi.Credentials(usage="initiate")
|
||||
creds_upn = str(creds.name)
|
||||
|
||||
logger.debug("Cached credentials found")
|
||||
logger.debug("Cached credentials UPN: %s", creds_upn)
|
||||
|
||||
# Remove the realm from user
|
||||
if creds_upn.find("@") != -1:
|
||||
creds_user, creds_realm = creds_upn.split("@", 1)
|
||||
else:
|
||||
creds_user = creds_upn
|
||||
creds_realm = None
|
||||
|
||||
upn = f"{self._username}@{realm}" if self._username else creds_upn
|
||||
|
||||
# The user from cached credentials matches with the given user?
|
||||
if self._username and self._username != creds_user:
|
||||
logger.debug(
|
||||
"The user from cached credentials doesn't match with the "
|
||||
"given user"
|
||||
)
|
||||
if self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
if creds_realm and creds_realm != realm and self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
except gssapi.raw.exceptions.ExpiredCredentialsError as err:
|
||||
if upn and self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
else:
|
||||
raise InterfaceError(f"Credentials has expired: {err}") from err
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
if upn and self._password is not None:
|
||||
creds = self._acquire_cred_with_password(upn)
|
||||
else:
|
||||
raise InterfaceError(
|
||||
f"Unable to retrieve cached credentials error: {err}"
|
||||
) from err
|
||||
|
||||
flags = (
|
||||
gssapi.RequirementFlag.mutual_authentication,
|
||||
gssapi.RequirementFlag.extended_error,
|
||||
gssapi.RequirementFlag.delegate_to_peer,
|
||||
)
|
||||
name = gssapi.Name(spn, name_type=gssapi.NameType.kerberos_principal)
|
||||
cname = name.canonicalize(gssapi.MechType.kerberos)
|
||||
self.context = gssapi.SecurityContext(
|
||||
name=cname, creds=creds, flags=sum(flags), usage="initiate"
|
||||
)
|
||||
|
||||
try:
|
||||
initial_client_token: Optional[bytes] = self.context.step()
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
raise InterfaceError(f"Unable to initiate security context: {err}") from err
|
||||
|
||||
logger.debug("Initial client token: %s", initial_client_token)
|
||||
return initial_client_token
|
||||
|
||||
def auth_continue(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge: the challenge for the negotiation.
|
||||
|
||||
Returns:
|
||||
tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
logger.debug("tgt_auth challenge: %s", tgt_auth_challenge)
|
||||
|
||||
resp: Optional[bytes] = self.context.step(tgt_auth_challenge)
|
||||
|
||||
logger.debug("Context step response: %s", resp)
|
||||
logger.debug("Context completed?: %s", self.context.complete)
|
||||
|
||||
return resp, self.context.complete
|
||||
|
||||
def auth_accept_close_handshake(self, message: bytes) -> bytes:
|
||||
"""Accept handshake and generate closing handshake message for server.
|
||||
|
||||
This method verifies the server authenticity from the given message
|
||||
and included signature and generates the closing handshake for the
|
||||
server.
|
||||
|
||||
When this method is invoked the security context is already established
|
||||
and the client and server can send GSSAPI formated secure messages.
|
||||
|
||||
To finish the authentication handshake the server sends a message
|
||||
with the security layer availability and the maximum buffer size.
|
||||
|
||||
Since the connector only uses the GSSAPI authentication mechanism to
|
||||
authenticate the user with the server, the server will verify clients
|
||||
message signature and terminate the GSSAPI authentication and send two
|
||||
messages; an authentication acceptance b'\x01\x00\x00\x08\x01' and a
|
||||
OK packet (that must be received after sent the returned message from
|
||||
this method).
|
||||
|
||||
Args:
|
||||
message: a wrapped gssapi message from the server.
|
||||
|
||||
Returns:
|
||||
bytearray (closing handshake message to be send to the server).
|
||||
"""
|
||||
if not self.context.complete:
|
||||
raise ProgrammingError("Security context is not completed")
|
||||
logger.debug("Server message: %s", message)
|
||||
logger.debug("GSSAPI flags in use: %s", self.context.actual_flags)
|
||||
try:
|
||||
unwraped = self.context.unwrap(message)
|
||||
logger.debug("Unwraped: %s", unwraped)
|
||||
except gssapi.raw.exceptions.BadMICError as err:
|
||||
logger.debug("Unable to unwrap server message: %s", err)
|
||||
raise InterfaceError(f"Unable to unwrap server message: {err}") from err
|
||||
|
||||
logger.debug("Unwrapped server message: %s", unwraped)
|
||||
# The message contents for the clients closing message:
|
||||
# - security level 1 byte, must be always 1.
|
||||
# - conciliated buffer size 3 bytes, without importance as no
|
||||
# further GSSAPI messages will be sends.
|
||||
response = bytearray(b"\x01\x00\x00\00")
|
||||
# Closing handshake must not be encrypted.
|
||||
logger.debug("Message response: %s", response)
|
||||
wraped = self.context.wrap(response, encrypt=False)
|
||||
logger.debug(
|
||||
"Wrapped message response: %s, length: %d",
|
||||
wraped[0],
|
||||
len(wraped[0]),
|
||||
)
|
||||
|
||||
return wraped.message
|
||||
|
||||
|
||||
class MySQLSSPIKerberosAuthPlugin(MySQLBaseKerberosAuthPlugin):
|
||||
"""Implement the MySQL Kerberos authentication plugin with Windows SSPI"""
|
||||
|
||||
context: Any = None
|
||||
clientauth: Any = None
|
||||
|
||||
@staticmethod
|
||||
def _parse_auth_data(packet: bytes) -> Tuple[str, str]:
|
||||
"""Parse authentication data.
|
||||
|
||||
Get the SPN and REALM from the authentication data packet.
|
||||
|
||||
Format:
|
||||
SPN string length two bytes <B1> <B2> +
|
||||
SPN string +
|
||||
UPN realm string length two bytes <B1> <B2> +
|
||||
UPN realm string
|
||||
|
||||
Returns:
|
||||
tuple: With 'spn' and 'realm'.
|
||||
"""
|
||||
spn_len = struct.unpack("<H", packet[:2])[0]
|
||||
packet = packet[2:]
|
||||
|
||||
spn = struct.unpack(f"<{spn_len}s", packet[:spn_len])[0]
|
||||
packet = packet[spn_len:]
|
||||
|
||||
realm_len = struct.unpack("<H", packet[:2])[0]
|
||||
realm = struct.unpack(f"<{realm_len}s", packet[2:])[0]
|
||||
|
||||
return spn.decode(), realm.decode()
|
||||
|
||||
def auth_response(
|
||||
self, auth_data: Optional[bytes] = None, **kwargs: Any
|
||||
) -> Optional[bytes]:
|
||||
"""Prepare the first message to the server.
|
||||
|
||||
Args:
|
||||
kwargs:
|
||||
ignore_auth_data (bool): if True, the provided auth data is ignored.
|
||||
"""
|
||||
logger.debug("auth_response for sspi")
|
||||
spn = None
|
||||
realm = None
|
||||
|
||||
if auth_data and not kwargs.get("ignore_auth_data", True):
|
||||
try:
|
||||
spn, realm = self._parse_auth_data(auth_data)
|
||||
except struct.error as err:
|
||||
raise InterruptedError(f"Invalid authentication data: {err}") from err
|
||||
|
||||
logger.debug("Service Principal: %s", spn)
|
||||
logger.debug("Realm: %s", realm)
|
||||
|
||||
if sspicon is None or sspi is None:
|
||||
raise ProgrammingError(
|
||||
'Package "pywin32" (Python for Win32 (pywin32) extensions)'
|
||||
" is not installed."
|
||||
)
|
||||
|
||||
flags = (sspicon.ISC_REQ_MUTUAL_AUTH, sspicon.ISC_REQ_DELEGATE)
|
||||
|
||||
if self._username and self._password:
|
||||
_auth_info = (self._username, realm, self._password)
|
||||
else:
|
||||
_auth_info = None
|
||||
|
||||
targetspn = spn
|
||||
logger.debug("targetspn: %s", targetspn)
|
||||
logger.debug("_auth_info is None: %s", _auth_info is None)
|
||||
|
||||
# The Security Support Provider Interface (SSPI) is an interface
|
||||
# that allows us to choose from a set of SSPs available in the
|
||||
# system; the idea of SSPI is to keep interface consistent no
|
||||
# matter what back end (a.k.a., SSP) we choose.
|
||||
|
||||
# When using SSPI we should not use Kerberos directly as SSP,
|
||||
# as remarked in [2], but we can use it indirectly via another
|
||||
# SSP named Negotiate that acts as an application layer between
|
||||
# SSPI and the other SSPs [1].
|
||||
|
||||
# Negotiate can select between Kerberos and NTLM on the fly;
|
||||
# it chooses Kerberos unless it cannot be used by one of the
|
||||
# systems involved in the authentication or the calling
|
||||
# application did not provide sufficient information to use
|
||||
# Kerberos.
|
||||
|
||||
# prefix: https://docs.microsoft.com/en-us/windows/win32/secauthn
|
||||
# [1] prefix/microsoft-negotiate?source=recommendations
|
||||
# [2] prefix/microsoft-kerberos?source=recommendations
|
||||
self.clientauth = sspi.ClientAuth(
|
||||
"Negotiate",
|
||||
targetspn=targetspn,
|
||||
auth_info=_auth_info,
|
||||
scflags=sum(flags),
|
||||
datarep=sspicon.SECURITY_NETWORK_DREP,
|
||||
)
|
||||
|
||||
try:
|
||||
data = None
|
||||
err, out_buf = self.clientauth.authorize(data)
|
||||
logger.debug("Context step err: %s", err)
|
||||
logger.debug("Context step out_buf: %s", out_buf)
|
||||
logger.debug("Context completed?: %s", self.clientauth.authenticated)
|
||||
initial_client_token = out_buf[0].Buffer
|
||||
logger.debug("pkg_info: %s", self.clientauth.pkg_info)
|
||||
except Exception as err:
|
||||
raise InterfaceError(f"Unable to initiate security context: {err}") from err
|
||||
|
||||
logger.debug("Initial client token: %s", initial_client_token)
|
||||
return initial_client_token
|
||||
|
||||
def auth_continue(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge: the challenge for the negotiation.
|
||||
|
||||
Returns:
|
||||
tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
logger.debug("tgt_auth challenge: %s", tgt_auth_challenge)
|
||||
|
||||
err, out_buf = self.clientauth.authorize(tgt_auth_challenge)
|
||||
|
||||
logger.debug("Context step err: %s", err)
|
||||
logger.debug("Context step out_buf: %s", out_buf)
|
||||
resp = out_buf[0].Buffer
|
||||
logger.debug("Context step resp: %s", resp)
|
||||
logger.debug("Context completed?: %s", self.clientauth.authenticated)
|
||||
|
||||
return resp, self.clientauth.authenticated
|
||||
+595
@@ -0,0 +1,595 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""LDAP SASL Authentication Plugin."""
|
||||
|
||||
import hmac
|
||||
|
||||
from base64 import b64decode, b64encode
|
||||
from hashlib import sha1, sha256
|
||||
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple
|
||||
from uuid import uuid4
|
||||
|
||||
from ..authentication import ERR_STATUS
|
||||
from ..errors import InterfaceError, ProgrammingError
|
||||
from ..logger import logger
|
||||
from ..types import StrOrBytes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
import gssapi
|
||||
except ImportError:
|
||||
raise ProgrammingError(
|
||||
"Module gssapi is required for GSSAPI authentication "
|
||||
"mechanism but was not found. Unable to authenticate "
|
||||
"with the server"
|
||||
) from None
|
||||
|
||||
from ..utils import (
|
||||
normalize_unicode_string as norm_ustr,
|
||||
validate_normalized_unicode_string as valid_norm,
|
||||
)
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLLdapSaslPasswordAuthPlugin"
|
||||
|
||||
|
||||
# pylint: disable=c-extension-no-member,no-member
|
||||
class MySQLLdapSaslPasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL ldap sasl authentication plugin.
|
||||
|
||||
The MySQL's ldap sasl authentication plugin support two authentication
|
||||
methods SCRAM-SHA-1 and GSSAPI (using Kerberos). This implementation only
|
||||
support SCRAM-SHA-1 and SCRAM-SHA-256.
|
||||
|
||||
SCRAM-SHA-1 amd SCRAM-SHA-256
|
||||
This method requires 2 messages from client and 2 responses from
|
||||
server.
|
||||
|
||||
The first message from client will be generated by prepare_password(),
|
||||
after receive the response from the server, it is required that this
|
||||
response is passed back to auth_continue() which will return the
|
||||
second message from the client. After send this second message to the
|
||||
server, the second server respond needs to be passed to auth_finalize()
|
||||
to finish the authentication process.
|
||||
"""
|
||||
|
||||
sasl_mechanisms: List[str] = ["SCRAM-SHA-1", "SCRAM-SHA-256", "GSSAPI"]
|
||||
def_digest_mode: Callable = sha1
|
||||
client_nonce: Optional[str] = None
|
||||
client_salt: Any = None
|
||||
server_salt: Optional[str] = None
|
||||
krb_service_principal: Optional[str] = None
|
||||
iterations: int = 0
|
||||
server_auth_var: Optional[str] = None
|
||||
target_name: Optional[gssapi.Name] = None
|
||||
ctx: gssapi.SecurityContext = None
|
||||
servers_first: Optional[str] = None
|
||||
server_nonce: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def _xor(bytes1: bytes, bytes2: bytes) -> bytes:
|
||||
return bytes([b1 ^ b2 for b1, b2 in zip(bytes1, bytes2)])
|
||||
|
||||
def _hmac(self, password: bytes, salt: bytes) -> bytes:
|
||||
digest_maker = hmac.new(password, salt, self.def_digest_mode)
|
||||
return digest_maker.digest()
|
||||
|
||||
def _hi(self, password: str, salt: bytes, count: int) -> bytes:
|
||||
"""Prepares Hi
|
||||
Hi(password, salt, iterations) where Hi(p,s,i) is defined as
|
||||
PBKDF2 (HMAC, p, s, i, output length of H).
|
||||
"""
|
||||
pw = password.encode()
|
||||
hi = self._hmac(pw, salt + b"\x00\x00\x00\x01")
|
||||
aux = hi
|
||||
for _ in range(count - 1):
|
||||
aux = self._hmac(pw, aux)
|
||||
hi = self._xor(hi, aux)
|
||||
return hi
|
||||
|
||||
@staticmethod
|
||||
def _normalize(string: str) -> str:
|
||||
norm_str = norm_ustr(string)
|
||||
broken_rule = valid_norm(norm_str)
|
||||
if broken_rule is not None:
|
||||
raise InterfaceError(f"broken_rule: {broken_rule}")
|
||||
return norm_str
|
||||
|
||||
def _first_message(self) -> bytes:
|
||||
"""This method generates the first message to the server to start the
|
||||
|
||||
The client-first message consists of a gs2-header,
|
||||
the desired username, and a randomly generated client nonce cnonce.
|
||||
|
||||
The first message from the server has the form:
|
||||
b'n,a=<user_name>,n=<user_name>,r=<client_nonce>
|
||||
|
||||
Returns client's first message
|
||||
"""
|
||||
cfm_fprnat = "n,a={user_name},n={user_name},r={client_nonce}"
|
||||
self.client_nonce = str(uuid4()).replace("-", "")
|
||||
cfm: StrOrBytes = cfm_fprnat.format(
|
||||
user_name=self._normalize(self._username),
|
||||
client_nonce=self.client_nonce,
|
||||
)
|
||||
|
||||
if isinstance(cfm, str):
|
||||
cfm = cfm.encode("utf8")
|
||||
return cfm
|
||||
|
||||
def _first_message_krb(self) -> Optional[bytes]:
|
||||
"""Get a TGT Authentication request and initiates security context.
|
||||
|
||||
This method will contact the Kerberos KDC in order of obtain a TGT.
|
||||
"""
|
||||
user_name = gssapi.raw.names.import_name(
|
||||
self._username.encode("utf8"), name_type=gssapi.NameType.user
|
||||
)
|
||||
|
||||
# Use defaults store = {'ccache': 'FILE:/tmp/krb5cc_1000'}#,
|
||||
# 'keytab':'/etc/some.keytab' }
|
||||
# Attempt to retrieve credential from default cache file.
|
||||
try:
|
||||
cred: Any = gssapi.Credentials()
|
||||
logger.debug(
|
||||
"# Stored credentials found, if password was given it will be ignored."
|
||||
)
|
||||
try:
|
||||
# validate credentials has not expired.
|
||||
cred.lifetime
|
||||
except gssapi.raw.exceptions.ExpiredCredentialsError as err:
|
||||
logger.warning(" Credentials has expired: %s", err)
|
||||
cred.acquire(user_name)
|
||||
raise InterfaceError(f"Credentials has expired: {err}") from err
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
if not self._password:
|
||||
raise InterfaceError(
|
||||
f"Unable to retrieve stored credentials error: {err}"
|
||||
) from err
|
||||
try:
|
||||
logger.debug("# Attempt to retrieve credentials with given password")
|
||||
acquire_cred_result = gssapi.raw.acquire_cred_with_password(
|
||||
user_name,
|
||||
self._password.encode("utf8"),
|
||||
usage="initiate",
|
||||
)
|
||||
cred = acquire_cred_result[0]
|
||||
except gssapi.raw.misc.GSSError as err2:
|
||||
raise ProgrammingError(
|
||||
f"Unable to retrieve credentials with the given password: {err2}"
|
||||
) from err
|
||||
|
||||
flags_l = (
|
||||
gssapi.RequirementFlag.mutual_authentication,
|
||||
gssapi.RequirementFlag.extended_error,
|
||||
gssapi.RequirementFlag.delegate_to_peer,
|
||||
)
|
||||
|
||||
if self.krb_service_principal:
|
||||
service_principal = self.krb_service_principal
|
||||
else:
|
||||
service_principal = "ldap/ldapauth"
|
||||
logger.debug("# service principal: %s", service_principal)
|
||||
servk = gssapi.Name(
|
||||
service_principal, name_type=gssapi.NameType.kerberos_principal
|
||||
)
|
||||
self.target_name = servk
|
||||
self.ctx = gssapi.SecurityContext(
|
||||
name=servk, creds=cred, flags=sum(flags_l), usage="initiate"
|
||||
)
|
||||
|
||||
try:
|
||||
# step() returns bytes | None, see documentation,
|
||||
# so this method could return a NULL payload.
|
||||
# ref: https://pythongssapi.github.io/<suffix>
|
||||
# suffix: python-gssapi/latest/gssapi.html#gssapi.sec_contexts.SecurityContext
|
||||
initial_client_token = self.ctx.step()
|
||||
except gssapi.raw.misc.GSSError as err:
|
||||
raise InterfaceError(f"Unable to initiate security context: {err}") from err
|
||||
|
||||
logger.debug("# initial client token: %s", initial_client_token)
|
||||
return initial_client_token
|
||||
|
||||
def auth_continue_krb(
|
||||
self, tgt_auth_challenge: Optional[bytes]
|
||||
) -> Tuple[Optional[bytes], bool]:
|
||||
"""Continue with the Kerberos TGT service request.
|
||||
|
||||
With the TGT authentication service given response generate a TGT
|
||||
service request. This method must be invoked sequentially (in a loop)
|
||||
until the security context is completed and an empty response needs to
|
||||
be send to acknowledge the server.
|
||||
|
||||
Args:
|
||||
tgt_auth_challenge the challenge for the negotiation.
|
||||
|
||||
Returns: tuple (bytearray TGS service request,
|
||||
bool True if context is completed otherwise False).
|
||||
"""
|
||||
logger.debug("tgt_auth challenge: %s", tgt_auth_challenge)
|
||||
|
||||
resp = self.ctx.step(tgt_auth_challenge)
|
||||
logger.debug("# context step response: %s", resp)
|
||||
logger.debug("# context completed?: %s", self.ctx.complete)
|
||||
|
||||
return resp, self.ctx.complete
|
||||
|
||||
def auth_accept_close_handshake(self, message: bytes) -> bytes:
|
||||
"""Accept handshake and generate closing handshake message for server.
|
||||
|
||||
This method verifies the server authenticity from the given message
|
||||
and included signature and generates the closing handshake for the
|
||||
server.
|
||||
|
||||
When this method is invoked the security context is already established
|
||||
and the client and server can send GSSAPI formated secure messages.
|
||||
|
||||
To finish the authentication handshake the server sends a message
|
||||
with the security layer availability and the maximum buffer size.
|
||||
|
||||
Since the connector only uses the GSSAPI authentication mechanism to
|
||||
authenticate the user with the server, the server will verify clients
|
||||
message signature and terminate the GSSAPI authentication and send two
|
||||
messages; an authentication acceptance b'\x01\x00\x00\x08\x01' and a
|
||||
OK packet (that must be received after sent the returned message from
|
||||
this method).
|
||||
|
||||
Args:
|
||||
message a wrapped hssapi message from the server.
|
||||
|
||||
Returns: bytearray closing handshake message to be send to the server.
|
||||
"""
|
||||
if not self.ctx.complete:
|
||||
raise ProgrammingError("Security context is not completed.")
|
||||
logger.debug("# servers message: %s", message)
|
||||
logger.debug("# GSSAPI flags in use: %s", self.ctx.actual_flags)
|
||||
try:
|
||||
unwraped = self.ctx.unwrap(message)
|
||||
logger.debug("# unwraped: %s", unwraped)
|
||||
except gssapi.raw.exceptions.BadMICError as err:
|
||||
raise InterfaceError(f"Unable to unwrap server message: {err}") from err
|
||||
|
||||
logger.debug("# unwrapped server message: %s", unwraped)
|
||||
# The message contents for the clients closing message:
|
||||
# - security level 1 byte, must be always 1.
|
||||
# - conciliated buffer size 3 bytes, without importance as no
|
||||
# further GSSAPI messages will be sends.
|
||||
response = bytearray(b"\x01\x00\x00\00")
|
||||
# Closing handshake must not be encrypted.
|
||||
logger.debug("# message response: %s", response)
|
||||
wraped = self.ctx.wrap(response, encrypt=False)
|
||||
logger.debug(
|
||||
"# wrapped message response: %s, length: %d",
|
||||
wraped[0],
|
||||
len(wraped[0]),
|
||||
)
|
||||
|
||||
return wraped.message
|
||||
|
||||
def auth_response(
|
||||
self,
|
||||
auth_data: bytes,
|
||||
**kwargs: Any,
|
||||
) -> Optional[bytes]:
|
||||
"""This method will prepare the fist message to the server.
|
||||
|
||||
Returns bytes to send to the server as the first message.
|
||||
"""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self._auth_data = auth_data
|
||||
|
||||
auth_mechanism = self._auth_data.decode()
|
||||
logger.debug("read_method_name_from_server: %s", auth_mechanism)
|
||||
if auth_mechanism not in self.sasl_mechanisms:
|
||||
auth_mechanisms = '", "'.join(self.sasl_mechanisms[:-1])
|
||||
raise InterfaceError(
|
||||
f'The sasl authentication method "{auth_mechanism}" requested '
|
||||
f'from the server is not supported. Only "{auth_mechanisms}" '
|
||||
f'and "{self.sasl_mechanisms[-1]}" are supported'
|
||||
)
|
||||
|
||||
if b"GSSAPI" in self._auth_data:
|
||||
return self._first_message_krb()
|
||||
|
||||
if self._auth_data == b"SCRAM-SHA-256":
|
||||
self.def_digest_mode = sha256
|
||||
|
||||
return self._first_message()
|
||||
|
||||
def _second_message(self) -> bytes:
|
||||
"""This method generates the second message to the server
|
||||
|
||||
Second message consist on the concatenation of the client and the
|
||||
server nonce, and cproof.
|
||||
|
||||
c=<n,a=<user_name>>,r=<server_nonce>,p=<client_proof>
|
||||
where:
|
||||
<client_proof>: xor(<client_key>, <client_signature>)
|
||||
|
||||
<client_key>: hmac(salted_password, b"Client Key")
|
||||
<client_signature>: hmac(<stored_key>, <auth_msg>)
|
||||
<stored_key>: h(<client_key>)
|
||||
<auth_msg>: <client_first_no_header>,<servers_first>,
|
||||
c=<client_header>,r=<server_nonce>
|
||||
<client_first_no_header>: n=<username>r=<client_nonce>
|
||||
"""
|
||||
if not self._auth_data:
|
||||
raise InterfaceError("Missing authentication data (seed)")
|
||||
|
||||
passw = self._normalize(self._password)
|
||||
salted_password = self._hi(passw, b64decode(self.server_salt), self.iterations)
|
||||
logger.debug("salted_password: %s", b64encode(salted_password).decode())
|
||||
|
||||
client_key = self._hmac(salted_password, b"Client Key")
|
||||
logger.debug("client_key: %s", b64encode(client_key).decode())
|
||||
|
||||
stored_key = self.def_digest_mode(client_key).digest()
|
||||
logger.debug("stored_key: %s", b64encode(stored_key).decode())
|
||||
|
||||
server_key = self._hmac(salted_password, b"Server Key")
|
||||
logger.debug("server_key: %s", b64encode(server_key).decode())
|
||||
|
||||
client_first_no_header = ",".join(
|
||||
[
|
||||
f"n={self._normalize(self._username)}",
|
||||
f"r={self.client_nonce}",
|
||||
]
|
||||
)
|
||||
logger.debug("client_first_no_header: %s", client_first_no_header)
|
||||
|
||||
client_header = b64encode(
|
||||
f"n,a={self._normalize(self._username)},".encode()
|
||||
).decode()
|
||||
|
||||
auth_msg = ",".join(
|
||||
[
|
||||
client_first_no_header,
|
||||
self.servers_first,
|
||||
f"c={client_header}",
|
||||
f"r={self.server_nonce}",
|
||||
]
|
||||
)
|
||||
logger.debug("auth_msg: %s", auth_msg)
|
||||
|
||||
client_signature = self._hmac(stored_key, auth_msg.encode())
|
||||
logger.debug("client_signature: %s", b64encode(client_signature).decode())
|
||||
|
||||
client_proof = self._xor(client_key, client_signature)
|
||||
logger.debug("client_proof: %s", b64encode(client_proof).decode())
|
||||
|
||||
self.server_auth_var = b64encode(
|
||||
self._hmac(server_key, auth_msg.encode())
|
||||
).decode()
|
||||
logger.debug("server_auth_var: %s", self.server_auth_var)
|
||||
|
||||
msg = ",".join(
|
||||
[
|
||||
f"c={client_header}",
|
||||
f"r={self.server_nonce}",
|
||||
f"p={b64encode(client_proof).decode()}",
|
||||
]
|
||||
)
|
||||
logger.debug("second_message: %s", msg)
|
||||
return msg.encode()
|
||||
|
||||
def _validate_first_reponse(self, servers_first: bytes) -> None:
|
||||
"""Validates first message from the server.
|
||||
|
||||
Extracts the server's salt and iterations from the servers 1st response.
|
||||
First message from the server is in the form:
|
||||
<server_salt>,i=<iterations>
|
||||
"""
|
||||
if not servers_first or not isinstance(servers_first, (bytearray, bytes)):
|
||||
raise InterfaceError(f"Unexpected server message: {repr(servers_first)}")
|
||||
try:
|
||||
servers_first_str = servers_first.decode()
|
||||
self.servers_first = servers_first_str
|
||||
r_server_nonce, s_salt, i_counter = servers_first_str.split(",")
|
||||
except ValueError:
|
||||
raise InterfaceError(
|
||||
f"Unexpected server message: {servers_first_str}"
|
||||
) from None
|
||||
if (
|
||||
not r_server_nonce.startswith("r=")
|
||||
or not s_salt.startswith("s=")
|
||||
or not i_counter.startswith("i=")
|
||||
):
|
||||
raise InterfaceError(
|
||||
f"Incomplete reponse from the server: {servers_first_str}"
|
||||
)
|
||||
if self.client_nonce in r_server_nonce:
|
||||
self.server_nonce = r_server_nonce[2:]
|
||||
logger.debug("server_nonce: %s", self.server_nonce)
|
||||
else:
|
||||
raise InterfaceError(
|
||||
"Unable to authenticate response: response not well formed "
|
||||
f"{servers_first_str}"
|
||||
)
|
||||
self.server_salt = s_salt[2:]
|
||||
logger.debug(
|
||||
"server_salt: %s length: %s",
|
||||
self.server_salt,
|
||||
len(self.server_salt),
|
||||
)
|
||||
try:
|
||||
i_counter = i_counter[2:]
|
||||
logger.debug("iterations: %s", i_counter)
|
||||
self.iterations = int(i_counter)
|
||||
except Exception as err:
|
||||
raise InterfaceError(
|
||||
f"Unable to authenticate: iterations not found {servers_first_str}"
|
||||
) from err
|
||||
|
||||
def auth_continue(self, servers_first_response: bytes) -> bytes:
|
||||
"""return the second message from the client.
|
||||
|
||||
Returns bytes to send to the server as the second message.
|
||||
"""
|
||||
self._validate_first_reponse(servers_first_response)
|
||||
return self._second_message()
|
||||
|
||||
def _validate_second_reponse(self, servers_second: bytearray) -> bool:
|
||||
"""Validates second message from the server.
|
||||
|
||||
The client and the server prove to each other they have the same Auth
|
||||
variable.
|
||||
|
||||
The second message from the server consist of the server's proof:
|
||||
server_proof = HMAC(<server_key>, <auth_msg>)
|
||||
where:
|
||||
<server_key>: hmac(<salted_password>, b"Server Key")
|
||||
<auth_msg>: <client_first_no_header>,<servers_first>,
|
||||
c=<client_header>,r=<server_nonce>
|
||||
|
||||
Our server_proof must be equal to the Auth variable send on this second
|
||||
response.
|
||||
"""
|
||||
if (
|
||||
not servers_second
|
||||
or not isinstance(servers_second, bytearray)
|
||||
or len(servers_second) <= 2
|
||||
or not servers_second.startswith(b"v=")
|
||||
):
|
||||
raise InterfaceError("The server's proof is not well formated")
|
||||
server_var = servers_second[2:].decode()
|
||||
logger.debug("server auth variable: %s", server_var)
|
||||
return self.server_auth_var == server_var
|
||||
|
||||
def auth_finalize(self, servers_second_response: bytearray) -> bool:
|
||||
"""finalize the authentication process.
|
||||
|
||||
Raises InterfaceError if the ervers_second_response is invalid.
|
||||
|
||||
Returns True in successful authentication False otherwise.
|
||||
"""
|
||||
if not self._validate_second_reponse(servers_second_response):
|
||||
raise InterfaceError(
|
||||
"Authentication failed: Unable to proof server identity"
|
||||
)
|
||||
return True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_ldap_sasl_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
logger.debug("# auth_data: %s", auth_data)
|
||||
self.krb_service_principal = kwargs.get("krb_service_principal")
|
||||
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
packet = sock.recv()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
if len(packet) >= 6 and packet[5] == 114 and packet[6] == 61: # 'r' and '='
|
||||
# Continue with sasl authentication
|
||||
dec_response = packet[5:]
|
||||
cresponse = self.auth_continue(dec_response)
|
||||
sock.send(cresponse)
|
||||
packet = sock.recv()
|
||||
if packet[5] == 118 and packet[6] == 61: # 'v' and '='
|
||||
if self.auth_finalize(packet[5:]):
|
||||
# receive packed OK
|
||||
packet = sock.recv()
|
||||
elif auth_data == b"GSSAPI" and packet[4] != ERR_STATUS:
|
||||
rcode_size = 5 # header size for the response status code.
|
||||
logger.debug("# Continue with sasl GSSAPI authentication")
|
||||
logger.debug("# response header: %s", packet[: rcode_size + 1])
|
||||
logger.debug("# response size: %s", len(packet))
|
||||
|
||||
logger.debug("# Negotiate a service request")
|
||||
complete = False
|
||||
tries = 0 # To avoid a infinite loop attempt no more than feedback messages
|
||||
while not complete and tries < 5:
|
||||
logger.debug("%s Attempt %s %s", "-" * 20, tries + 1, "-" * 20)
|
||||
logger.debug("<< server response: %s", packet)
|
||||
logger.debug("# response code: %s", packet[: rcode_size + 1])
|
||||
step, complete = self.auth_continue_krb(packet[rcode_size:])
|
||||
logger.debug(" >> response to server: %s", step)
|
||||
sock.send(step or b"")
|
||||
packet = sock.recv()
|
||||
tries += 1
|
||||
if not complete:
|
||||
raise InterfaceError(
|
||||
f"Unable to fulfill server request after {tries} "
|
||||
f"attempts. Last server response: {packet}"
|
||||
)
|
||||
logger.debug(
|
||||
" last GSSAPI response from server: %s length: %d",
|
||||
packet,
|
||||
len(packet),
|
||||
)
|
||||
last_step = self.auth_accept_close_handshake(packet[rcode_size:])
|
||||
logger.debug(
|
||||
" >> last response to server: %s length: %d",
|
||||
last_step,
|
||||
len(last_step),
|
||||
)
|
||||
sock.send(last_step)
|
||||
# Receive final handshake from server
|
||||
packet = sock.recv()
|
||||
logger.debug("<< final handshake from server: %s", packet)
|
||||
|
||||
# receive OK packet from server.
|
||||
packet = sock.recv()
|
||||
logger.debug("<< ok packet from server: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
|
||||
|
||||
# pylint: enable=c-extension-no-member,no-member
|
||||
@@ -0,0 +1,234 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# mypy: disable-error-code="arg-type,union-attr,call-arg"
|
||||
|
||||
"""OCI Authentication Plugin."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from .. import errors
|
||||
from ..logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
from cryptography.exceptions import UnsupportedAlgorithm
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.asymmetric.types import PRIVATE_KEY_TYPES
|
||||
except ImportError:
|
||||
raise errors.ProgrammingError("Package 'cryptography' is not installed") from None
|
||||
|
||||
try:
|
||||
from oci import config, exceptions
|
||||
except ImportError:
|
||||
raise errors.ProgrammingError(
|
||||
"Package 'oci' (Oracle Cloud Infrastructure Python SDK) is not installed"
|
||||
) from None
|
||||
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLOCIAuthPlugin"
|
||||
OCI_SECURITY_TOKEN_MAX_SIZE = 10 * 1024 # In bytes
|
||||
OCI_SECURITY_TOKEN_TOO_LARGE = "Ephemeral security token is too large (10KB max)"
|
||||
OCI_SECURITY_TOKEN_FILE_NOT_AVAILABLE = (
|
||||
"Ephemeral security token file ('security_token_file') could not be read"
|
||||
)
|
||||
OCI_PROFILE_MISSING_PROPERTIES = (
|
||||
"OCI configuration file does not contain a 'fingerprint' or 'key_file' entry"
|
||||
)
|
||||
|
||||
|
||||
class MySQLOCIAuthPlugin(MySQLAuthPlugin):
|
||||
"""Implement the MySQL OCI IAM authentication plugin."""
|
||||
|
||||
context: Any = None
|
||||
oci_config_profile: str = "DEFAULT"
|
||||
oci_config_file: str = config.DEFAULT_LOCATION
|
||||
|
||||
@staticmethod
|
||||
def _prepare_auth_response(signature: bytes, oci_config: Dict[str, Any]) -> str:
|
||||
"""Prepare client's authentication response
|
||||
|
||||
Prepares client's authentication response in JSON format
|
||||
Args:
|
||||
signature (bytes): server's nonce to be signed by client.
|
||||
oci_config (dict): OCI configuration object.
|
||||
|
||||
Returns:
|
||||
str: JSON string with the following format:
|
||||
{"fingerprint": str, "signature": str, "token": base64.base64.base64}
|
||||
|
||||
Raises:
|
||||
ProgrammingError: If the ephemeral security token file can't be open or the
|
||||
token is too large.
|
||||
"""
|
||||
signature_64 = b64encode(signature)
|
||||
auth_response = {
|
||||
"fingerprint": oci_config["fingerprint"],
|
||||
"signature": signature_64.decode(),
|
||||
}
|
||||
|
||||
# The security token, if it exists, should be a JWT (JSON Web Token), consisted
|
||||
# of a base64-encoded header, body, and signature, separated by '.',
|
||||
# e.g. "Base64.Base64.Base64", stored in a file at the path specified by the
|
||||
# security_token_file configuration property
|
||||
if oci_config.get("security_token_file"):
|
||||
try:
|
||||
security_token_file = Path(oci_config["security_token_file"])
|
||||
# Check if token exceeds the maximum size
|
||||
if security_token_file.stat().st_size > OCI_SECURITY_TOKEN_MAX_SIZE:
|
||||
raise errors.ProgrammingError(OCI_SECURITY_TOKEN_TOO_LARGE)
|
||||
auth_response["token"] = security_token_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as err:
|
||||
raise errors.ProgrammingError(
|
||||
OCI_SECURITY_TOKEN_FILE_NOT_AVAILABLE
|
||||
) from err
|
||||
return json.dumps(auth_response, separators=(",", ":"))
|
||||
|
||||
@staticmethod
|
||||
def _get_private_key(key_path: str) -> PRIVATE_KEY_TYPES:
|
||||
"""Get the private_key form the given location"""
|
||||
try:
|
||||
with open(os.path.expanduser(key_path), "rb") as key_file:
|
||||
private_key = serialization.load_pem_private_key(
|
||||
key_file.read(),
|
||||
password=None,
|
||||
)
|
||||
except (TypeError, OSError, ValueError, UnsupportedAlgorithm) as err:
|
||||
raise errors.ProgrammingError(
|
||||
"An error occurred while reading the API_KEY from "
|
||||
f'"{key_path}": {err}'
|
||||
)
|
||||
|
||||
return private_key
|
||||
|
||||
def _get_valid_oci_config(self) -> Dict[str, Any]:
|
||||
"""Get a valid OCI config from the given configuration file path"""
|
||||
error_list = []
|
||||
req_keys = {
|
||||
"fingerprint": (lambda x: len(x) > 32),
|
||||
"key_file": (lambda x: os.path.exists(os.path.expanduser(x))),
|
||||
}
|
||||
|
||||
oci_config: Dict[str, Any] = {}
|
||||
try:
|
||||
# key_file is validated by oci.config if present
|
||||
oci_config = config.from_file(
|
||||
self.oci_config_file or config.DEFAULT_LOCATION,
|
||||
self.oci_config_profile or "DEFAULT",
|
||||
)
|
||||
for req_key, req_value in req_keys.items():
|
||||
try:
|
||||
# Verify parameter in req_key is present and valid
|
||||
if oci_config[req_key] and not req_value(oci_config[req_key]):
|
||||
error_list.append(f'Parameter "{req_key}" is invalid')
|
||||
except KeyError:
|
||||
error_list.append(f"Does not contain parameter {req_key}")
|
||||
except (
|
||||
exceptions.ConfigFileNotFound,
|
||||
exceptions.InvalidConfig,
|
||||
exceptions.InvalidKeyFilePath,
|
||||
exceptions.InvalidPrivateKey,
|
||||
exceptions.ProfileNotFound,
|
||||
) as err:
|
||||
error_list.append(str(err))
|
||||
|
||||
# Raise errors if any
|
||||
if error_list:
|
||||
raise errors.ProgrammingError(
|
||||
f"Invalid oci-config-file: {self.oci_config_file}. "
|
||||
f"Errors found: {error_list}"
|
||||
)
|
||||
|
||||
return oci_config
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_oci_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Prepare authentication string for the server."""
|
||||
logger.debug("server nonce: %s, len %d", auth_data, len(auth_data))
|
||||
|
||||
oci_config = self._get_valid_oci_config()
|
||||
|
||||
private_key = self._get_private_key(oci_config["key_file"])
|
||||
signature = private_key.sign(auth_data, padding.PKCS1v15(), hashes.SHA256())
|
||||
|
||||
auth_response = self._prepare_auth_response(signature, oci_config)
|
||||
logger.debug("authentication response: %s", auth_response)
|
||||
return auth_response.encode()
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
self.oci_config_file = kwargs.get("oci_config_file", "DEFAULT")
|
||||
self.oci_config_profile = kwargs.get(
|
||||
"oci_config_profile", config.DEFAULT_LOCATION
|
||||
)
|
||||
logger.debug("# oci configuration file path: %s", self.oci_config_file)
|
||||
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
packet = sock.recv()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
# Copyright (c) 2024, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""OpenID Authentication Plugin."""
|
||||
|
||||
import re
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
from mysql.connector import utils
|
||||
|
||||
from .. import errors
|
||||
from ..logger import logger
|
||||
from ..network import MySQLSocket
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLOpenIDConnectAuthPlugin"
|
||||
OPENID_TOKEN_MAX_SIZE = 10 * 1024 # In bytes
|
||||
|
||||
|
||||
class MySQLOpenIDConnectAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL OpenID Connect Authentication Plugin."""
|
||||
|
||||
_openid_capability_flag: bytes = utils.int1store(1)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_openid_connect_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _validate_openid_token(token: str) -> bool:
|
||||
"""Helper method used to validate OpenID Connect token
|
||||
|
||||
The Token is represented as a JSON Web Token (JWT) consists of a
|
||||
base64-encoded header, body, and signature, separated by '.' e.g.,
|
||||
"Base64url.Base64url.Base64url". The First part of the token contains
|
||||
the header, the second part contains payload and the third part contains
|
||||
signature. These token parts should be Base64 URLSafe i.e., Token cannot
|
||||
contain characters other than a-z, A-Z, 0-9 and special characters '-', '_'.
|
||||
|
||||
Args:
|
||||
token (str): Base64url-encoded OpenID connect token fetched from
|
||||
the file path passed via `openid_token_file` connection
|
||||
argument.
|
||||
|
||||
Returns:
|
||||
bool: Signal indicating whether the token is valid or not.
|
||||
"""
|
||||
header_payload_sig: List[str] = token.split(".")
|
||||
if len(header_payload_sig) != 3:
|
||||
# invalid structure
|
||||
return False
|
||||
urlsafe_pattern = re.compile("^[a-zA-Z0-9-_]*$")
|
||||
return all(
|
||||
(
|
||||
len(token_part) and urlsafe_pattern.search(token_part) is not None
|
||||
for token_part in header_payload_sig
|
||||
)
|
||||
)
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> bytes:
|
||||
"""Prepares authentication string for the server.
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
The OpenID Connect authorization response follows the pattern :-
|
||||
int<1> capability flag
|
||||
string<lenenc> id token
|
||||
|
||||
Raises:
|
||||
InterfaceError: If the connection is insecure or the OpenID Token is too large,
|
||||
invalid or non-existent.
|
||||
ProgrammingError: If the OpenID Token file could not be read.
|
||||
"""
|
||||
try:
|
||||
# Check if the connection is secure
|
||||
if self.requires_ssl and not self._ssl_enabled:
|
||||
raise errors.InterfaceError(f"{self.name} requires SSL")
|
||||
|
||||
# Validate the file
|
||||
token_file_path: str = kwargs.get("openid_token_file", None)
|
||||
openid_token_file: Path = Path(token_file_path)
|
||||
# Check if token exceeds the maximum size
|
||||
if openid_token_file.stat().st_size > OPENID_TOKEN_MAX_SIZE:
|
||||
raise errors.InterfaceError(
|
||||
"The OpenID Connect token file size is too large (> 10KB)"
|
||||
)
|
||||
openid_token: str = openid_token_file.read_text(encoding="utf-8")
|
||||
openid_token = openid_token.strip()
|
||||
# Validate the JWT Token
|
||||
if not self._validate_openid_token(openid_token):
|
||||
raise errors.InterfaceError("The OpenID Connect Token is invalid")
|
||||
|
||||
# build the auth_response packet
|
||||
auth_response: List[bytes] = [
|
||||
self._openid_capability_flag,
|
||||
utils.lc_int(len(openid_token)),
|
||||
openid_token.encode(),
|
||||
]
|
||||
return b"".join(auth_response)
|
||||
except (SyntaxError, TypeError, OSError, UnicodeError) as err:
|
||||
raise errors.ProgrammingError(
|
||||
"The OpenID Connect Token File (openid_token_file) could not be read"
|
||||
) from err
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: MySQLSocket, auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
|
||||
Raises:
|
||||
InterfaceError: If a NULL auth response is received from auth_response method.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
packet = sock.recv()
|
||||
logger.debug("# server response packet: %s", packet)
|
||||
|
||||
return bytes(packet)
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""WebAuthn Authentication Plugin."""
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
from .. import errors, utils
|
||||
from ..logger import logger
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
try:
|
||||
from fido2.cbor import dump_bytes as cbor_dump_bytes
|
||||
from fido2.client import Fido2Client, UserInteraction
|
||||
from fido2.hid import CtapHidDevice
|
||||
from fido2.webauthn import PublicKeyCredentialRequestOptions
|
||||
except ImportError as import_err:
|
||||
raise errors.ProgrammingError(
|
||||
"Module fido2 is required for WebAuthn authentication mechanism but was "
|
||||
"not found. Unable to authenticate with the server"
|
||||
) from import_err
|
||||
|
||||
try:
|
||||
from fido2.pcsc import CtapPcscDevice
|
||||
|
||||
CTAP_PCSC_DEVICE_AVAILABLE = True
|
||||
except ModuleNotFoundError:
|
||||
CTAP_PCSC_DEVICE_AVAILABLE = False
|
||||
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLWebAuthnAuthPlugin"
|
||||
|
||||
|
||||
class ClientInteraction(UserInteraction):
|
||||
"""Provides user interaction to the Client."""
|
||||
|
||||
def __init__(self, callback: Optional[Callable] = None):
|
||||
self.callback = callback
|
||||
self.msg = (
|
||||
"Please insert FIDO device and perform gesture action for authentication "
|
||||
"to complete."
|
||||
)
|
||||
|
||||
def prompt_up(self) -> None:
|
||||
"""Prompt message for the user interaction with the FIDO device."""
|
||||
if self.callback is None:
|
||||
print(self.msg)
|
||||
else:
|
||||
self.callback(self.msg)
|
||||
|
||||
|
||||
class MySQLWebAuthnAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL WebAuthn authentication plugin."""
|
||||
|
||||
client: Optional[Fido2Client] = None
|
||||
callback: Optional[Callable] = None
|
||||
options: dict = {"rpId": None, "challenge": None, "allowCredentials": []}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "authentication_webauthn_client"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def get_assertion_response(
|
||||
self, credential_id: Optional[bytearray] = None
|
||||
) -> bytes:
|
||||
"""Get assertion from authenticator and return the response.
|
||||
|
||||
Args:
|
||||
credential_id (Optional[bytearray]): The credential ID.
|
||||
|
||||
Returns:
|
||||
bytearray: The response packet with the data from the assertion.
|
||||
"""
|
||||
if self.client is None:
|
||||
raise errors.InterfaceError("No WebAuthn client found")
|
||||
|
||||
if credential_id is not None:
|
||||
# If credential_id is not None, it's because the FIDO device does not
|
||||
# support resident keys and the credential_id was requested from the server
|
||||
self.options["allowCredentials"] = [
|
||||
{
|
||||
"id": credential_id,
|
||||
"type": "public-key",
|
||||
}
|
||||
]
|
||||
|
||||
# Get assertion from authenticator
|
||||
assertion = self.client.get_assertion(
|
||||
PublicKeyCredentialRequestOptions.from_dict(self.options)
|
||||
)
|
||||
number_of_assertions = len(assertion.get_assertions())
|
||||
client_data_json = b""
|
||||
|
||||
# Build response packet
|
||||
#
|
||||
# Format:
|
||||
# int<1> 0x02 (2) status tag
|
||||
# int<lenenc> number of assertions length encoded number of assertions
|
||||
# string authenticator data variable length raw binary string
|
||||
# string signed challenge variable length raw binary string
|
||||
# ...
|
||||
# ...
|
||||
# string authenticator data variable length raw binary string
|
||||
# string signed challenge variable length raw binary string
|
||||
# string ClientDataJSON variable length raw binary string
|
||||
packet = utils.lc_int(2)
|
||||
packet += utils.lc_int(number_of_assertions)
|
||||
|
||||
# Add authenticator data and signed challenge for each assertion
|
||||
for i in range(number_of_assertions):
|
||||
assertion_response = assertion.get_response(i)
|
||||
|
||||
# string<lenenc> authenticator_data
|
||||
authenticator_data = cbor_dump_bytes(assertion_response.authenticator_data)
|
||||
|
||||
# string<lenenc> signed_challenge
|
||||
signature = assertion_response.signature
|
||||
|
||||
packet += utils.lc_int(len(authenticator_data))
|
||||
packet += authenticator_data
|
||||
packet += utils.lc_int(len(signature))
|
||||
packet += signature
|
||||
|
||||
# string<lenenc> client_data_json
|
||||
client_data_json = assertion_response.client_data
|
||||
|
||||
packet += utils.lc_int(len(client_data_json))
|
||||
packet += client_data_json
|
||||
|
||||
logger.debug("WebAuthn - payload response packet: %s", packet)
|
||||
return packet
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Find authenticator device and check if supports resident keys.
|
||||
|
||||
It also creates a Fido2Client using the relying party ID from the server.
|
||||
|
||||
Raises:
|
||||
InterfaceError: When the FIDO device is not found.
|
||||
|
||||
Returns:
|
||||
bytes: 2 if the authenticator supports resident keys else 1.
|
||||
"""
|
||||
try:
|
||||
packets, capability = utils.read_int(auth_data, 1)
|
||||
challenge, rp_id = utils.read_lc_string_list(packets)
|
||||
self.options["challenge"] = challenge
|
||||
self.options["rpId"] = rp_id.decode()
|
||||
logger.debug("WebAuthn - capability: %d", capability)
|
||||
logger.debug("WebAuthn - challenge: %s", self.options["challenge"])
|
||||
logger.debug("WebAuthn - relying party id: %s", self.options["rpId"])
|
||||
except ValueError as err:
|
||||
raise errors.InterfaceError(
|
||||
"Unable to parse MySQL WebAuthn authentication data"
|
||||
) from err
|
||||
|
||||
# Locate a device
|
||||
device = next(CtapHidDevice.list_devices(), None)
|
||||
if device is not None:
|
||||
logger.debug("WebAuthn - Use USB HID channel")
|
||||
elif CTAP_PCSC_DEVICE_AVAILABLE:
|
||||
device = next(CtapPcscDevice.list_devices(), None)
|
||||
|
||||
if device is None:
|
||||
raise errors.InterfaceError("No FIDO device found")
|
||||
|
||||
# Set up a FIDO 2 client using the origin relying party id
|
||||
self.client = Fido2Client(
|
||||
device,
|
||||
f"https://{self.options['rpId']}",
|
||||
user_interaction=ClientInteraction(self.callback),
|
||||
)
|
||||
|
||||
if not self.client.info.options.get("rk"):
|
||||
logger.debug("WebAuthn - Authenticator doesn't support resident keys")
|
||||
return b"1"
|
||||
|
||||
logger.debug("WebAuthn - Authenticator with support for resident key found")
|
||||
return b"2"
|
||||
|
||||
def auth_more_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth more data` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Authentication method data (from a packet representing
|
||||
an `auth more data` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
_, credential_id = utils.read_lc_string(auth_data)
|
||||
|
||||
response = self.get_assertion_response(credential_id) # type: ignore[arg-type]
|
||||
|
||||
logger.debug("WebAuthn - request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
pkt = bytes(sock.recv())
|
||||
logger.debug("WebAuthn - server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
webauth_callback = kwargs.get("webauthn_callback") or kwargs.get(
|
||||
"fido_callback"
|
||||
)
|
||||
self.callback = (
|
||||
utils.import_object(webauth_callback)
|
||||
if isinstance(webauth_callback, str)
|
||||
else webauth_callback
|
||||
)
|
||||
|
||||
response = self.auth_response(auth_data)
|
||||
credential_id = None
|
||||
|
||||
if response == b"1":
|
||||
# Authenticator doesn't support resident keys, request credential_id
|
||||
logger.debug("WebAuthn - request credential_id")
|
||||
sock.send(utils.lc_int(int(response)))
|
||||
|
||||
# return a packet representing an `auth more data` response
|
||||
return bytes(sock.recv())
|
||||
|
||||
response = self.get_assertion_response(credential_id)
|
||||
|
||||
logger.debug("WebAuthn - request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
pkt = bytes(sock.recv())
|
||||
logger.debug("WebAuthn - server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,159 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Caching SHA2 Password Authentication Plugin."""
|
||||
|
||||
import struct
|
||||
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from ..errors import InterfaceError
|
||||
from ..logger import logger
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLCachingSHA2PasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLCachingSHA2PasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL caching_sha2_password authentication plugin
|
||||
|
||||
Note that encrypting using RSA is not supported since the Python
|
||||
Standard Library does not provide this OpenSSL functionality.
|
||||
"""
|
||||
|
||||
perform_full_authentication: int = 4
|
||||
|
||||
def _scramble(self, auth_data: bytes) -> bytes:
|
||||
"""Return a scramble of the password using a Nonce sent by the
|
||||
server.
|
||||
|
||||
The scramble is of the form:
|
||||
XOR(SHA2(password), SHA2(SHA2(SHA2(password)), Nonce))
|
||||
"""
|
||||
if not auth_data:
|
||||
raise InterfaceError("Missing authentication data (seed)")
|
||||
|
||||
if not self._password:
|
||||
return b""
|
||||
|
||||
hash1 = sha256(self._password.encode()).digest()
|
||||
hash2 = sha256()
|
||||
hash2.update(sha256(hash1).digest())
|
||||
hash2.update(auth_data)
|
||||
hash2_digest = hash2.digest()
|
||||
xored = [h1 ^ h2 for (h1, h2) in zip(hash1, hash2_digest)]
|
||||
hash3 = struct.pack("32B", *xored)
|
||||
return hash3
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "caching_sha2_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Make the client's authorization response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
"""
|
||||
if not auth_data:
|
||||
return None
|
||||
if len(auth_data) > 1:
|
||||
return self._scramble(auth_data)
|
||||
if auth_data[0] == self.perform_full_authentication:
|
||||
# return password as clear text.
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
return None
|
||||
|
||||
def auth_more_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth more data` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Authentication method data (from a packet representing
|
||||
an `auth more data` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response:
|
||||
sock.send(response)
|
||||
|
||||
return bytes(sock.recv())
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
pkt = bytes(sock.recv())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,104 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Clear Password Authentication Plugin."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from .. import errors
|
||||
from ..logger import logger
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLClearPasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLClearPasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL Clear Password authentication plugin"""
|
||||
|
||||
def _prepare_password(self) -> bytes:
|
||||
"""Prepare and return password as as clear text.
|
||||
|
||||
Returns:
|
||||
bytes: Prepared password.
|
||||
"""
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "mysql_clear_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return True
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Return the prepared password to send to MySQL.
|
||||
|
||||
Raises:
|
||||
InterfaceError: When SSL is required by not enabled.
|
||||
|
||||
Returns:
|
||||
str: The prepared password.
|
||||
"""
|
||||
if self.requires_ssl and not self._ssl_enabled:
|
||||
raise errors.InterfaceError(f"{self.name} requires SSL")
|
||||
return self._prepare_password()
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
pkt = bytes(sock.recv())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Native Password Authentication Plugin."""
|
||||
|
||||
import struct
|
||||
|
||||
from hashlib import sha1
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from ..errors import InterfaceError
|
||||
from ..logger import logger
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLNativePasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLNativePasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL Native Password authentication plugin"""
|
||||
|
||||
def _prepare_password(self, auth_data: bytes) -> bytes:
|
||||
"""Prepares and returns password as native MySQL 4.1+ password"""
|
||||
if not auth_data:
|
||||
raise InterfaceError("Missing authentication data (seed)")
|
||||
|
||||
if not self._password:
|
||||
return b""
|
||||
|
||||
hash4 = None
|
||||
try:
|
||||
hash1 = sha1(self._password.encode()).digest()
|
||||
hash2 = sha1(hash1).digest()
|
||||
hash3 = sha1(auth_data + hash2).digest()
|
||||
xored = [h1 ^ h3 for (h1, h3) in zip(hash1, hash3)]
|
||||
hash4 = struct.pack("20B", *xored)
|
||||
except (struct.error, TypeError) as err:
|
||||
raise InterfaceError(f"Failed scrambling password; {err}") from err
|
||||
|
||||
return hash4
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "mysql_native_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return False
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Make the client's authorization response.
|
||||
|
||||
Args:
|
||||
auth_data: Authorization data.
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Client's authorization response.
|
||||
"""
|
||||
return self._prepare_password(auth_data)
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
pkt = bytes(sock.recv())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,108 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""SHA256 Password Authentication Plugin."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from .. import errors
|
||||
from ..logger import logger
|
||||
from . import MySQLAuthPlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..network import MySQLSocket
|
||||
|
||||
AUTHENTICATION_PLUGIN_CLASS = "MySQLSHA256PasswordAuthPlugin"
|
||||
|
||||
|
||||
class MySQLSHA256PasswordAuthPlugin(MySQLAuthPlugin):
|
||||
"""Class implementing the MySQL SHA256 authentication plugin
|
||||
|
||||
Note that encrypting using RSA is not supported since the Python
|
||||
Standard Library does not provide this OpenSSL functionality.
|
||||
"""
|
||||
|
||||
def _prepare_password(self) -> bytes:
|
||||
"""Prepare and return password as as clear text.
|
||||
|
||||
Returns:
|
||||
password (bytes): Prepared password.
|
||||
"""
|
||||
return self._password.encode() + b"\x00"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Plugin official name."""
|
||||
return "sha256_password"
|
||||
|
||||
@property
|
||||
def requires_ssl(self) -> bool:
|
||||
"""Signals whether or not SSL is required."""
|
||||
return True
|
||||
|
||||
def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]:
|
||||
"""Return the prepared password to send to MySQL.
|
||||
|
||||
Raises:
|
||||
InterfaceError: When SSL is required by not enabled.
|
||||
|
||||
Returns:
|
||||
str: The prepared password.
|
||||
"""
|
||||
if self.requires_ssl and not self.ssl_enabled:
|
||||
raise errors.InterfaceError(f"{self.name} requires SSL")
|
||||
return self._prepare_password()
|
||||
|
||||
def auth_switch_response(
|
||||
self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any
|
||||
) -> bytes:
|
||||
"""Handles server's `auth switch request` response.
|
||||
|
||||
Args:
|
||||
sock: Pointer to the socket connection.
|
||||
auth_data: Plugin provided data (extracted from a packet
|
||||
representing an `auth switch request` response).
|
||||
kwargs: Custom configuration to be passed to the auth plugin
|
||||
when invoked. The parameters defined here will override the ones
|
||||
defined in the auth plugin itself.
|
||||
|
||||
Returns:
|
||||
packet: Last server's response after back-and-forth
|
||||
communication.
|
||||
"""
|
||||
response = self.auth_response(auth_data, **kwargs)
|
||||
if response is None:
|
||||
raise errors.InterfaceError("Got a NULL auth response")
|
||||
|
||||
logger.debug("# request: %s size: %s", response, len(response))
|
||||
sock.send(response)
|
||||
|
||||
pkt = bytes(sock.recv())
|
||||
logger.debug("# server response packet: %s", pkt)
|
||||
|
||||
return pkt
|
||||
@@ -0,0 +1,699 @@
|
||||
# Copyright (c) 2013, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Implementing pooling of connections to MySQL servers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, Any, Dict, NoReturn, Optional, Tuple, Type, Union
|
||||
from uuid import uuid4
|
||||
|
||||
try:
|
||||
import dns.exception
|
||||
import dns.resolver
|
||||
except ImportError:
|
||||
HAVE_DNSPYTHON = False
|
||||
else:
|
||||
HAVE_DNSPYTHON = True
|
||||
|
||||
try:
|
||||
from .connection_cext import CMySQLConnection
|
||||
except ImportError:
|
||||
CMySQLConnection = None # type: ignore[misc]
|
||||
|
||||
from .connection import MySQLConnection
|
||||
from .constants import CNX_POOL_ARGS, DEFAULT_CONFIGURATION
|
||||
from .errors import (
|
||||
Error,
|
||||
InterfaceError,
|
||||
NotSupportedError,
|
||||
PoolError,
|
||||
ProgrammingError,
|
||||
)
|
||||
from .optionfiles import read_option_files
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .abstracts import MySQLConnectionAbstract
|
||||
|
||||
CONNECTION_POOL_LOCK = threading.RLock()
|
||||
CNX_POOL_MAXSIZE = 32
|
||||
CNX_POOL_MAXNAMESIZE = 64
|
||||
CNX_POOL_NAMEREGEX = re.compile(r"[^a-zA-Z0-9._:\-*$#]")
|
||||
ERROR_NO_CEXT = "MySQL Connector/Python C Extension not available"
|
||||
MYSQL_CNX_CLASS: Union[type, Tuple[type, ...]] = (
|
||||
MySQLConnection if CMySQLConnection is None else (MySQLConnection, CMySQLConnection)
|
||||
)
|
||||
|
||||
_CONNECTION_POOLS: Dict[str, MySQLConnectionPool] = {}
|
||||
|
||||
|
||||
def _get_pooled_connection(**kwargs: Any) -> PooledMySQLConnection:
|
||||
"""Return a pooled MySQL connection."""
|
||||
# If no pool name specified, generate one
|
||||
pool_name = (
|
||||
kwargs["pool_name"] if "pool_name" in kwargs else generate_pool_name(**kwargs)
|
||||
)
|
||||
|
||||
if kwargs.get("use_pure") is False and CMySQLConnection is None:
|
||||
raise ImportError(ERROR_NO_CEXT)
|
||||
|
||||
# Setup the pool, ensuring only 1 thread can update at a time
|
||||
with CONNECTION_POOL_LOCK:
|
||||
if pool_name not in _CONNECTION_POOLS:
|
||||
_CONNECTION_POOLS[pool_name] = MySQLConnectionPool(**kwargs)
|
||||
elif isinstance(_CONNECTION_POOLS[pool_name], MySQLConnectionPool):
|
||||
# pool_size must be the same
|
||||
check_size = _CONNECTION_POOLS[pool_name].pool_size
|
||||
if "pool_size" in kwargs and kwargs["pool_size"] != check_size:
|
||||
raise PoolError("Size can not be changed for active pools.")
|
||||
|
||||
# Return pooled connection
|
||||
try:
|
||||
return _CONNECTION_POOLS[pool_name].get_connection()
|
||||
except AttributeError:
|
||||
raise InterfaceError(
|
||||
f"Failed getting connection from pool '{pool_name}'"
|
||||
) from None
|
||||
|
||||
|
||||
def _get_failover_connection(
|
||||
**kwargs: Any,
|
||||
) -> Union[PooledMySQLConnection, MySQLConnectionAbstract]:
|
||||
"""Return a MySQL connection and try to failover if needed.
|
||||
|
||||
An InterfaceError is raise when no MySQL is available. ValueError is
|
||||
raised when the failover server configuration contains an illegal
|
||||
connection argument. Supported arguments are user, password, host, port,
|
||||
unix_socket and database. ValueError is also raised when the failover
|
||||
argument was not provided.
|
||||
|
||||
Returns MySQLConnection instance.
|
||||
"""
|
||||
config = kwargs.copy()
|
||||
try:
|
||||
failover = config["failover"]
|
||||
except KeyError:
|
||||
raise ValueError("failover argument not provided") from None
|
||||
del config["failover"]
|
||||
|
||||
support_cnx_args = set(
|
||||
[
|
||||
"user",
|
||||
"password",
|
||||
"host",
|
||||
"port",
|
||||
"unix_socket",
|
||||
"database",
|
||||
"pool_name",
|
||||
"pool_size",
|
||||
"priority",
|
||||
]
|
||||
)
|
||||
|
||||
# First check if we can add all use the configuration
|
||||
priority_count = 0
|
||||
for server in failover:
|
||||
diff = set(server.keys()) - support_cnx_args
|
||||
if diff:
|
||||
arg = "s" if len(diff) > 1 else ""
|
||||
lst = ", ".join(diff)
|
||||
raise ValueError(
|
||||
f"Unsupported connection argument {arg} in failover: {lst}"
|
||||
)
|
||||
if hasattr(server, "priority"):
|
||||
priority_count += 1
|
||||
|
||||
server["priority"] = server.get("priority", 100)
|
||||
if server["priority"] < 0 or server["priority"] > 100:
|
||||
raise InterfaceError(
|
||||
"Priority value should be in the range of 0 to 100, "
|
||||
f"got : {server['priority']}"
|
||||
)
|
||||
if not isinstance(server["priority"], int):
|
||||
raise InterfaceError(
|
||||
"Priority value should be an integer in the range of 0 to "
|
||||
f"100, got : {server['priority']}"
|
||||
)
|
||||
|
||||
if 0 < priority_count < len(failover):
|
||||
raise ProgrammingError(
|
||||
"You must either assign no priority to any "
|
||||
"of the routers or give a priority for "
|
||||
"every router"
|
||||
)
|
||||
|
||||
server_directory = {}
|
||||
server_priority_list = []
|
||||
for server in sorted(failover, key=lambda x: x["priority"], reverse=True):
|
||||
if server["priority"] not in server_directory:
|
||||
server_directory[server["priority"]] = [server]
|
||||
server_priority_list.append(server["priority"])
|
||||
else:
|
||||
server_directory[server["priority"]].append(server)
|
||||
|
||||
for priority in server_priority_list:
|
||||
failover_list = server_directory[priority]
|
||||
for _ in range(len(failover_list)):
|
||||
last = len(failover_list) - 1
|
||||
index = random.randint(0, last)
|
||||
server = failover_list.pop(index)
|
||||
new_config = config.copy()
|
||||
new_config.update(server)
|
||||
new_config.pop("priority", None)
|
||||
try:
|
||||
return connect(**new_config)
|
||||
except Error:
|
||||
# If we failed to connect, we try the next server
|
||||
pass
|
||||
|
||||
raise InterfaceError("Unable to connect to any of the target hosts")
|
||||
|
||||
|
||||
def connect(
|
||||
*args: Any, **kwargs: Any
|
||||
) -> Union[PooledMySQLConnection, MySQLConnectionAbstract]:
|
||||
"""Creates or gets a MySQL connection object.
|
||||
|
||||
In its simpliest form, `connect()` will open a connection to a
|
||||
MySQL server and return a `MySQLConnectionAbstract` subclass
|
||||
object such as `MySQLConnection` or `CMySQLConnection`.
|
||||
|
||||
When any connection pooling arguments are given, for example `pool_name`
|
||||
or `pool_size`, a pool is created or a previously one is used to return
|
||||
a `PooledMySQLConnection`.
|
||||
|
||||
Args:
|
||||
*args: N/A.
|
||||
**kwargs: For a complete list of possible arguments, see [1]. If no arguments
|
||||
are given, it uses the already configured or default values.
|
||||
|
||||
Returns:
|
||||
A `MySQLConnectionAbstract` subclass instance (such as `MySQLConnection` or
|
||||
`CMySQLConnection`) or a `PooledMySQLConnection` instance.
|
||||
|
||||
Examples:
|
||||
A connection with the MySQL server can be established using either the
|
||||
`mysql.connector.connect()` method or a `MySQLConnectionAbstract` subclass:
|
||||
```
|
||||
>>> from mysql.connector import MySQLConnection, HAVE_CEXT
|
||||
>>>
|
||||
>>> cnx1 = mysql.connector.connect(user='joe', database='test')
|
||||
>>> cnx2 = MySQLConnection(user='joe', database='test')
|
||||
>>>
|
||||
>>> cnx3 = None
|
||||
>>> if HAVE_CEXT:
|
||||
>>> from mysql.connector import CMySQLConnection
|
||||
>>> cnx3 = CMySQLConnection(user='joe', database='test')
|
||||
```
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
# DNS SRV
|
||||
dns_srv = kwargs.pop("dns_srv") if "dns_srv" in kwargs else False
|
||||
|
||||
if not isinstance(dns_srv, bool):
|
||||
raise InterfaceError("The value of 'dns-srv' must be a boolean")
|
||||
|
||||
if dns_srv:
|
||||
if not HAVE_DNSPYTHON:
|
||||
raise InterfaceError(
|
||||
"MySQL host configuration requested DNS "
|
||||
"SRV. This requires the Python dnspython "
|
||||
"module. Please refer to documentation"
|
||||
)
|
||||
if "unix_socket" in kwargs:
|
||||
raise InterfaceError(
|
||||
"Using Unix domain sockets with DNS SRV lookup is not allowed"
|
||||
)
|
||||
if "port" in kwargs:
|
||||
raise InterfaceError(
|
||||
"Specifying a port number with DNS SRV lookup is not allowed"
|
||||
)
|
||||
if "failover" in kwargs:
|
||||
raise InterfaceError(
|
||||
"Specifying multiple hostnames with DNS SRV look up is not allowed"
|
||||
)
|
||||
if "host" not in kwargs:
|
||||
kwargs["host"] = DEFAULT_CONFIGURATION["host"]
|
||||
|
||||
try:
|
||||
srv_records = dns.resolver.query(kwargs["host"], "SRV")
|
||||
except dns.exception.DNSException:
|
||||
raise InterfaceError(
|
||||
f"Unable to locate any hosts for '{kwargs['host']}'"
|
||||
) from None
|
||||
|
||||
failover = []
|
||||
for srv in srv_records:
|
||||
failover.append(
|
||||
{
|
||||
"host": srv.target.to_text(omit_final_dot=True),
|
||||
"port": srv.port,
|
||||
"priority": srv.priority,
|
||||
"weight": srv.weight,
|
||||
}
|
||||
)
|
||||
|
||||
failover.sort(key=lambda x: (x["priority"], -x["weight"]))
|
||||
kwargs["failover"] = [
|
||||
{"host": srv["host"], "port": srv["port"]} for srv in failover
|
||||
]
|
||||
|
||||
# Option files
|
||||
if "read_default_file" in kwargs:
|
||||
kwargs["option_files"] = kwargs["read_default_file"]
|
||||
kwargs.pop("read_default_file")
|
||||
|
||||
if "option_files" in kwargs:
|
||||
new_config = read_option_files(**kwargs)
|
||||
return connect(**new_config)
|
||||
|
||||
# Failover
|
||||
if "failover" in kwargs:
|
||||
return _get_failover_connection(**kwargs)
|
||||
|
||||
# Pooled connections
|
||||
try:
|
||||
if any(key in kwargs for key in CNX_POOL_ARGS):
|
||||
return _get_pooled_connection(**kwargs)
|
||||
except NameError:
|
||||
# No pooling
|
||||
pass
|
||||
|
||||
# Use C Extension by default
|
||||
use_pure = kwargs.get("use_pure", False)
|
||||
if "use_pure" in kwargs:
|
||||
del kwargs["use_pure"] # Remove 'use_pure' from kwargs
|
||||
if not use_pure and CMySQLConnection is None:
|
||||
raise ImportError(ERROR_NO_CEXT)
|
||||
|
||||
if CMySQLConnection and not use_pure:
|
||||
return CMySQLConnection(*args, **kwargs)
|
||||
return MySQLConnection(*args, **kwargs)
|
||||
|
||||
|
||||
def generate_pool_name(**kwargs: Any) -> str:
|
||||
"""Generate a pool name
|
||||
|
||||
This function takes keyword arguments, usually the connection
|
||||
arguments for MySQLConnection, and tries to generate a name for
|
||||
a pool.
|
||||
|
||||
Raises PoolError when no name can be generated.
|
||||
|
||||
Returns a string.
|
||||
"""
|
||||
parts = []
|
||||
for key in ("host", "port", "user", "database"):
|
||||
try:
|
||||
parts.append(str(kwargs[key]))
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if not parts:
|
||||
raise PoolError("Failed generating pool name; specify pool_name")
|
||||
|
||||
return "_".join(parts)
|
||||
|
||||
|
||||
class PooledMySQLConnection:
|
||||
"""Class holding a MySQL Connection in a pool
|
||||
|
||||
PooledMySQLConnection is used by MySQLConnectionPool to return an
|
||||
instance holding a MySQL connection. It works like a MySQLConnection
|
||||
except for methods like close() and config().
|
||||
|
||||
The close()-method will add the connection back to the pool rather
|
||||
than disconnecting from the MySQL server.
|
||||
|
||||
Configuring the connection have to be done through the MySQLConnectionPool
|
||||
method set_config(). Using config() on pooled connection will raise a
|
||||
PoolError.
|
||||
|
||||
Attributes:
|
||||
pool_name (str): Returns the name of the connection pool to which the
|
||||
connection belongs.
|
||||
"""
|
||||
|
||||
def __init__(self, pool: MySQLConnectionPool, cnx: MySQLConnectionAbstract) -> None:
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
pool: A `MySQLConnectionPool` instance.
|
||||
cnx: A `MySQLConnectionAbstract` subclass instance.
|
||||
"""
|
||||
if not isinstance(pool, MySQLConnectionPool):
|
||||
raise AttributeError("pool should be a MySQLConnectionPool")
|
||||
if not isinstance(cnx, MYSQL_CNX_CLASS):
|
||||
raise AttributeError("cnx should be a MySQLConnection")
|
||||
self._cnx_pool: MySQLConnectionPool = pool
|
||||
self._cnx: MySQLConnectionAbstract = cnx
|
||||
|
||||
def __enter__(self) -> PooledMySQLConnection:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Type[BaseException],
|
||||
exc_value: BaseException,
|
||||
traceback: TracebackType,
|
||||
) -> None:
|
||||
self.close()
|
||||
|
||||
def __getattr__(self, attr: Any) -> Any:
|
||||
"""Calls attributes of the MySQLConnection instance"""
|
||||
return getattr(self._cnx, attr)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Do not close, but adds connection back to pool.
|
||||
|
||||
For a pooled connection, close() does not actually close it but returns it
|
||||
to the pool and makes it available for subsequent connection requests. If the
|
||||
pool configuration parameters are changed, a returned connection is closed
|
||||
and reopened with the new configuration before being returned from the pool
|
||||
again in response to a connection request.
|
||||
"""
|
||||
try:
|
||||
cnx = self._cnx
|
||||
if self._cnx_pool.reset_session:
|
||||
cnx.reset_session()
|
||||
finally:
|
||||
self._cnx_pool.add_connection(cnx)
|
||||
self._cnx = None
|
||||
|
||||
@staticmethod
|
||||
def config(**kwargs: Any) -> NoReturn:
|
||||
"""Configuration is done through the pool.
|
||||
|
||||
For pooled connections, the `config()` method raises a `PoolError`
|
||||
exception. Configuration for pooled connections should be done
|
||||
using the pool object.
|
||||
"""
|
||||
raise PoolError(
|
||||
"Configuration for pooled connections should be done through the "
|
||||
"pool itself"
|
||||
)
|
||||
|
||||
@property
|
||||
def pool_name(self) -> str:
|
||||
"""Returns the name of the connection pool to which the connection belongs."""
|
||||
return self._cnx_pool.pool_name
|
||||
|
||||
|
||||
class MySQLConnectionPool:
|
||||
"""Class defining a pool of MySQL connections"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pool_size: int = 5,
|
||||
pool_name: Optional[str] = None,
|
||||
pool_reset_session: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Constructor.
|
||||
|
||||
Initialize a MySQL connection pool with a maximum number of
|
||||
connections set to `pool_size`. The rest of the keywords
|
||||
arguments, kwargs, are configuration arguments for MySQLConnection
|
||||
instances.
|
||||
|
||||
Args:
|
||||
pool_name: The pool name. If this argument is not given, Connector/Python
|
||||
automatically generates the name, composed from whichever of
|
||||
the host, port, user, and database connection arguments are
|
||||
given in kwargs, in that order.
|
||||
pool_size: The pool size. If this argument is not given, the default is 5.
|
||||
pool_reset_session: Whether to reset session variables when the connection
|
||||
is returned to the pool.
|
||||
**kwargs: Optional additional connection arguments, as described in [1].
|
||||
|
||||
Examples:
|
||||
```
|
||||
>>> dbconfig = {
|
||||
>>> "database": "test",
|
||||
>>> "user": "joe",
|
||||
>>> }
|
||||
>>> cnxpool = mysql.connector.pooling.MySQLConnectionPool(pool_name = "mypool",
|
||||
>>> pool_size = 3,
|
||||
>>> **dbconfig)
|
||||
```
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
self._pool_size: Optional[int] = None
|
||||
self._pool_name: Optional[str] = None
|
||||
self._reset_session = pool_reset_session
|
||||
self._set_pool_size(pool_size)
|
||||
self._set_pool_name(pool_name or generate_pool_name(**kwargs))
|
||||
self._cnx_config: Dict[str, Any] = {}
|
||||
self._cnx_queue: queue.Queue[MySQLConnectionAbstract] = queue.Queue(
|
||||
self._pool_size
|
||||
)
|
||||
self._config_version = uuid4()
|
||||
|
||||
if kwargs:
|
||||
self.set_config(**kwargs)
|
||||
cnt = 0
|
||||
while cnt < self._pool_size:
|
||||
self.add_connection()
|
||||
cnt += 1
|
||||
|
||||
@property
|
||||
def pool_name(self) -> str:
|
||||
"""Returns the name of the connection pool."""
|
||||
return self._pool_name
|
||||
|
||||
@property
|
||||
def pool_size(self) -> int:
|
||||
"""Returns number of connections managed by the pool."""
|
||||
return self._pool_size
|
||||
|
||||
@property
|
||||
def reset_session(self) -> bool:
|
||||
"""Returns whether to reset session."""
|
||||
return self._reset_session
|
||||
|
||||
def set_config(self, **kwargs: Any) -> None:
|
||||
"""Set the connection configuration for `MySQLConnectionAbstract` subclass instances.
|
||||
|
||||
This method sets the configuration used for creating `MySQLConnectionAbstract`
|
||||
subclass instances such as `MySQLConnection`. See [1] for valid
|
||||
connection arguments.
|
||||
|
||||
Args:
|
||||
**kwargs: Connection arguments - for a complete list of possible
|
||||
arguments, see [1].
|
||||
|
||||
Raises:
|
||||
PoolError: When a connection argument is not valid, missing
|
||||
or not supported by `MySQLConnectionAbstract`.
|
||||
|
||||
References:
|
||||
[1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
|
||||
"""
|
||||
if not kwargs:
|
||||
return
|
||||
|
||||
with CONNECTION_POOL_LOCK:
|
||||
try:
|
||||
test_cnx = connect()
|
||||
test_cnx.config(**kwargs)
|
||||
self._cnx_config = kwargs
|
||||
self._config_version = uuid4()
|
||||
except AttributeError as err:
|
||||
raise PoolError(f"Connection configuration not valid: {err}") from err
|
||||
|
||||
def _set_pool_size(self, pool_size: int) -> None:
|
||||
"""Set the size of the pool
|
||||
|
||||
This method sets the size of the pool but it will not resize the pool.
|
||||
|
||||
Raises an AttributeError when the pool_size is not valid. Invalid size
|
||||
is 0, negative or higher than pooling.CNX_POOL_MAXSIZE.
|
||||
"""
|
||||
if pool_size <= 0 or pool_size > CNX_POOL_MAXSIZE:
|
||||
raise AttributeError(
|
||||
"Pool size should be higher than 0 and lower or equal to "
|
||||
f"{CNX_POOL_MAXSIZE}"
|
||||
)
|
||||
self._pool_size = pool_size
|
||||
|
||||
def _set_pool_name(self, pool_name: str) -> None:
|
||||
r"""Set the name of the pool.
|
||||
|
||||
This method checks the validity and sets the name of the pool.
|
||||
|
||||
Raises an AttributeError when pool_name contains illegal characters
|
||||
([^a-zA-Z0-9._\-*$#]) or is longer than pooling.CNX_POOL_MAXNAMESIZE.
|
||||
"""
|
||||
if CNX_POOL_NAMEREGEX.search(pool_name):
|
||||
raise AttributeError(f"Pool name '{pool_name}' contains illegal characters")
|
||||
if len(pool_name) > CNX_POOL_MAXNAMESIZE:
|
||||
raise AttributeError(f"Pool name '{pool_name}' is too long")
|
||||
self._pool_name = pool_name
|
||||
|
||||
def _queue_connection(self, cnx: MySQLConnectionAbstract) -> None:
|
||||
"""Put connection back in the queue
|
||||
|
||||
This method is putting a connection back in the queue. It will not
|
||||
acquire a lock as the methods using _queue_connection() will have it
|
||||
set.
|
||||
|
||||
Raises `PoolError` on errors.
|
||||
"""
|
||||
if not isinstance(cnx, MYSQL_CNX_CLASS):
|
||||
raise PoolError(
|
||||
"Connection instance not subclass of MySQLConnectionAbstract"
|
||||
)
|
||||
|
||||
try:
|
||||
self._cnx_queue.put(cnx, block=False)
|
||||
except queue.Full as err:
|
||||
raise PoolError("Failed adding connection; queue is full") from err
|
||||
|
||||
def add_connection(self, cnx: Optional[MySQLConnectionAbstract] = None) -> None:
|
||||
"""Adds a connection to the pool.
|
||||
|
||||
This method instantiates a `MySQLConnection` using the configuration
|
||||
passed when initializing the `MySQLConnectionPool` instance or using
|
||||
the `set_config()` method.
|
||||
If cnx is a `MySQLConnection` instance, it will be added to the
|
||||
queue.
|
||||
|
||||
Args:
|
||||
cnx: The `MySQLConnectionAbstract` subclass object to be added to
|
||||
the pool. If this argument is missing (aka `None`), the pool
|
||||
creates a new connection and adds it.
|
||||
|
||||
Raises:
|
||||
PoolError: When no configuration is set, when no more
|
||||
connection can be added (maximum reached) or when the connection
|
||||
can not be instantiated.
|
||||
"""
|
||||
with CONNECTION_POOL_LOCK:
|
||||
if not self._cnx_config:
|
||||
raise PoolError("Connection configuration not available")
|
||||
|
||||
if self._cnx_queue.full():
|
||||
raise PoolError("Failed adding connection; queue is full")
|
||||
|
||||
if not cnx:
|
||||
cnx = connect(**self._cnx_config) # type: ignore[assignment]
|
||||
try:
|
||||
if (
|
||||
self._reset_session
|
||||
and self._cnx_config["compress"]
|
||||
and cnx.server_version < (5, 7, 3)
|
||||
):
|
||||
raise NotSupportedError(
|
||||
"Pool reset session is not supported with "
|
||||
"compression for MySQL server version 5.7.2 "
|
||||
"or earlier"
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
cnx.pool_config_version = self._config_version
|
||||
else:
|
||||
if not isinstance(cnx, MYSQL_CNX_CLASS):
|
||||
raise PoolError(
|
||||
"Connection instance not subclass of MySQLConnectionAbstract"
|
||||
)
|
||||
|
||||
self._queue_connection(cnx)
|
||||
|
||||
def get_connection(self) -> PooledMySQLConnection:
|
||||
"""Gets a connection from the pool.
|
||||
|
||||
This method returns an PooledMySQLConnection instance which
|
||||
has a reference to the pool that created it, and the next available
|
||||
MySQL connection.
|
||||
|
||||
When the MySQL connection is not connect, a reconnect is attempted.
|
||||
|
||||
Returns:
|
||||
A `PooledMySQLConnection` instance.
|
||||
|
||||
Raises:
|
||||
PoolError: On errors.
|
||||
"""
|
||||
with CONNECTION_POOL_LOCK:
|
||||
try:
|
||||
cnx = self._cnx_queue.get(block=False)
|
||||
except queue.Empty as err:
|
||||
raise PoolError("Failed getting connection; pool exhausted") from err
|
||||
|
||||
if (
|
||||
not cnx.is_connected()
|
||||
or self._config_version != cnx.pool_config_version
|
||||
):
|
||||
cnx.config(**self._cnx_config)
|
||||
try:
|
||||
cnx.reconnect()
|
||||
except InterfaceError:
|
||||
# Failed to reconnect, give connection back to pool
|
||||
self._queue_connection(cnx)
|
||||
raise
|
||||
cnx.pool_config_version = self._config_version
|
||||
|
||||
return PooledMySQLConnection(self, cnx)
|
||||
|
||||
def _remove_connections(self) -> int:
|
||||
"""Close all connections
|
||||
|
||||
This method closes all connections. It returns the number
|
||||
of connections it closed.
|
||||
|
||||
Used mostly for tests.
|
||||
|
||||
Returns int.
|
||||
"""
|
||||
with CONNECTION_POOL_LOCK:
|
||||
cnt = 0
|
||||
cnxq = self._cnx_queue
|
||||
while cnxq.qsize():
|
||||
try:
|
||||
cnx = cnxq.get(block=False)
|
||||
cnx.disconnect()
|
||||
cnt += 1
|
||||
except queue.Empty:
|
||||
return cnt
|
||||
except PoolError:
|
||||
raise
|
||||
except Error:
|
||||
# Any other error when closing means connection is closed
|
||||
pass
|
||||
|
||||
return cnt
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2014, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
@@ -0,0 +1,168 @@
|
||||
# Copyright (c) 2024, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""TLS ciphersuites and versions."""
|
||||
|
||||
# Generated from the OSSA cipher list
|
||||
# version: 3.4
|
||||
# date: 2024-04-11
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
APPROVED_TLS_VERSIONS: List[str] = ["TLSv1.2", "TLSv1.3"]
|
||||
"""Approved TLS versions."""
|
||||
|
||||
DEPRECATED_TLS_VERSIONS: List[str] = []
|
||||
"""Deprecated TLS versions."""
|
||||
|
||||
UNACCEPTABLE_TLS_VERSIONS: List[str] = ["TLSv1", "TLSv1.0", "TLSv1.1"]
|
||||
"""Unacceptable TLS versions."""
|
||||
|
||||
MANDATORY_TLS_CIPHERSUITES: Dict[str, Dict[str, str]] = {
|
||||
"TLSv1.2": {
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256": "ECDHE-ECDSA-AES128-GCM-SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384": "ECDHE-ECDSA-AES256-GCM-SHA384",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256": "ECDHE-RSA-AES128-GCM-SHA256",
|
||||
},
|
||||
"TLSv1.3": {},
|
||||
}
|
||||
"""Access dictionary by TLS version that translates from cipher suites IANI (key)
|
||||
to OpenSSL name (value)."""
|
||||
|
||||
APPROVED_TLS_CIPHERSUITES: Dict[str, Dict[str, str]] = {
|
||||
"TLSv1.2": {
|
||||
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384": "ECDHE-RSA-AES256-GCM-SHA384",
|
||||
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256": "ECDHE-ECDSA-CHACHA20-POLY1305",
|
||||
"TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256": "ECDHE-RSA-CHACHA20-POLY1305",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_CCM": "ECDHE-ECDSA-AES256-CCM",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CCM": "ECDHE-ECDSA-AES128-CCM",
|
||||
},
|
||||
"TLSv1.3": {
|
||||
"TLS_AES_128_GCM_SHA256": "TLS_AES_128_GCM_SHA256",
|
||||
"TLS_AES_256_GCM_SHA384": "TLS_AES_256_GCM_SHA384",
|
||||
"TLS_CHACHA20_POLY1305_SHA256": "TLS_CHACHA20_POLY1305_SHA256",
|
||||
"TLS_AES_128_CCM_SHA256": "TLS_AES_128_CCM_SHA256",
|
||||
},
|
||||
}
|
||||
"""Access dictionary by TLS version that translates from cipher suites IANI (key)
|
||||
to OpenSSL name (value)."""
|
||||
|
||||
DEPRECATED_TLS_CIPHERSUITES: Dict[str, Dict[str, str]] = {
|
||||
"TLSv1.2": {
|
||||
"TLS_DHE_RSA_WITH_AES_128_GCM_SHA256": "DHE-RSA-AES128-GCM-SHA256",
|
||||
"TLS_DHE_RSA_WITH_AES_256_GCM_SHA384": "DHE-RSA-AES256-GCM-SHA384",
|
||||
"TLS_DHE_RSA_WITH_AES_256_CCM": "DHE-RSA-AES256-CCM",
|
||||
"TLS_DHE_RSA_WITH_AES_128_CCM": "DHE-RSA-AES128-CCM",
|
||||
"TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256": "DHE-RSA-CHACHA20-POLY1305",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8": "ECDHE-ECDSA-AES256-CCM8",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8": "ECDHE-ECDSA-AES128-CCM8",
|
||||
"TLS_DHE_RSA_WITH_AES_256_CCM_8": "DHE-RSA-AES256-CCM8",
|
||||
"TLS_DHE_RSA_WITH_AES_128_CCM_8": "DHE-RSA-AES128-CCM8",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256": "ECDHE-ECDSA-AES128-SHA256",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256": "ECDHE-RSA-AES128-SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384": "ECDHE-ECDSA-AES256-SHA384",
|
||||
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384": "ECDHE-RSA-AES256-SHA384",
|
||||
"TLS_DHE_DSS_WITH_AES_256_GCM_SHA384": "DHE-DSS-AES256-GCM-SHA384",
|
||||
"TLS_DHE_DSS_WITH_AES_128_GCM_SHA256": "DHE-DSS-AES128-GCM-SHA256",
|
||||
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA256": "DHE-DSS-AES128-SHA256",
|
||||
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA256": "DHE-DSS-AES256-SHA256",
|
||||
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA256": "DHE-RSA-AES256-SHA256",
|
||||
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA256": "DHE-RSA-AES128-SHA256",
|
||||
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256": "DHE-RSA-CAMELLIA256-SHA256",
|
||||
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256": "DHE-RSA-CAMELLIA128-SHA256",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA": "ECDHE-RSA-AES128-SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA": "ECDHE-ECDSA-AES128-SHA",
|
||||
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA": "ECDHE-RSA-AES256-SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA": "ECDHE-ECDSA-AES256-SHA",
|
||||
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA": "DHE-DSS-AES128-SHA",
|
||||
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA": "DHE-RSA-AES128-SHA",
|
||||
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA": "DHE-RSA-AES256-SHA",
|
||||
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA": "DHE-DSS-AES256-SHA",
|
||||
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA": "DHE-RSA-CAMELLIA256-SHA",
|
||||
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA": "DHE-RSA-CAMELLIA128-SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256": "ECDH-ECDSA-AES128-SHA256",
|
||||
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256": "ECDH-RSA-AES128-SHA256",
|
||||
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384": "ECDH-RSA-AES256-SHA384",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384": "ECDH-ECDSA-AES256-SHA384",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA": "ECDH-ECDSA-AES128-SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA": "ECDH-ECDSA-AES256-SHA",
|
||||
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA": "ECDH-RSA-AES128-SHA",
|
||||
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA": "ECDH-RSA-AES256-SHA",
|
||||
"TLS_RSA_WITH_AES_128_GCM_SHA256": "AES128-GCM-SHA256",
|
||||
"TLS_RSA_WITH_AES_128_CCM": "AES128-CCM",
|
||||
"TLS_RSA_WITH_AES_128_CCM_8": "AES128-CCM8",
|
||||
"TLS_RSA_WITH_AES_256_GCM_SHA384": "AES256-GCM-SHA384",
|
||||
"TLS_RSA_WITH_AES_256_CCM": "AES256-CCM",
|
||||
"TLS_RSA_WITH_AES_256_CCM_8": "AES256-CCM8",
|
||||
"TLS_RSA_WITH_AES_128_CBC_SHA256": "AES128-SHA256",
|
||||
"TLS_RSA_WITH_AES_256_CBC_SHA256": "AES256-SHA256",
|
||||
"TLS_RSA_WITH_AES_128_CBC_SHA": "AES128-SHA",
|
||||
"TLS_RSA_WITH_AES_256_CBC_SHA": "AES256-SHA",
|
||||
"TLS_RSA_WITH_CAMELLIA_256_CBC_SHA": "CAMELLIA256-SHA",
|
||||
"TLS_RSA_WITH_CAMELLIA_128_CBC_SHA": "CAMELLIA128-SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256": "ECDH-ECDSA-AES128-GCM-SHA256",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384": "ECDH-ECDSA-AES256-GCM-SHA384",
|
||||
"TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256": "ECDH-RSA-AES128-GCM-SHA256",
|
||||
"TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384": "ECDH-RSA-AES256-GCM-SHA384",
|
||||
},
|
||||
"TLSv1.3": {"TLS_AES_128_CCM_8_SHA256": "TLS_AES_128_CCM_8_SHA256"},
|
||||
}
|
||||
"""Access dictionary by TLS version that translates from cipher suites IANI (key)
|
||||
to OpenSSL name (value)."""
|
||||
|
||||
UNACCEPTABLE_TLS_CIPHERSUITES: Dict[str, Dict[str, str]] = {
|
||||
"TLSv1.2": {
|
||||
"TLS_DH_RSA_WITH_AES_128_CBC_SHA256": "DH-RSA-AES128-SHA256",
|
||||
"TLS_DH_RSA_WITH_AES_256_CBC_SHA256": "DH-RSA-AES256-SHA256",
|
||||
"TLS_DH_DSS_WITH_AES_128_CBC_SHA256": "DH-DSS-AES128-SHA256",
|
||||
"TLS_DH_DSS_WITH_AES_128_CBC_SHA": "DH-DSS-AES128-SHA",
|
||||
"TLS_DH_DSS_WITH_AES_256_CBC_SHA": "DH-DSS-AES256-SHA",
|
||||
"TLS_DH_DSS_WITH_AES_256_CBC_SHA256": "DH-DSS-AES256-SHA256",
|
||||
"TLS_DH_RSA_WITH_AES_128_CBC_SHA": "DH-RSA-AES128-SHA",
|
||||
"TLS_DH_RSA_WITH_AES_256_CBC_SHA": "DH-RSA-AES256-SHA",
|
||||
"TLS_DH_DSS_WITH_AES_128_GCM_SHA256": "DH-DSS-AES128-GCM-SHA256",
|
||||
"TLS_DH_DSS_WITH_AES_256_GCM_SHA384": "DH-DSS-AES256-GCM-SHA384",
|
||||
"TLS_DH_RSA_WITH_AES_128_GCM_SHA256": "DH-RSA-AES128-GCM-SHA256",
|
||||
"TLS_DH_RSA_WITH_AES_256_GCM_SHA384": "DH-RSA-AES256-GCM-SHA384",
|
||||
"TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA": "DH-DSS-DES-CBC3-SHA",
|
||||
"TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA": "DH-RSA-DES-CBC3-SHA",
|
||||
"TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA": "EDH-DSS-DES-CBC3-SHA",
|
||||
"TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA": "EDH-RSA-DES-CBC3-SHA",
|
||||
"TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA": "ECDH-RSA-DES-CBC3-SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA": "ECDH-ECDSA-DES-CBC3-SHA",
|
||||
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA": "ECDHE-RSA-DES-CBC3-SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA": "ECDHE-ECDSA-DES-CBC3-SHA",
|
||||
"TLS_RSA_WITH_3DES_EDE_CBC_SHA": "DES-CBC3-SHA",
|
||||
"TLS_KRB5_WITH_3DES_EDE_CBC_SHA": "KRB5-DES-CBC3-SHA",
|
||||
"TLS_KRB5_WITH_3DES_EDE_CBC_MD5": "KRB5-DES-CBC3-MD5",
|
||||
"TLS_KRB5_WITH_IDEA_CBC_SHA": "KRB5-IDEA-CBC-SHA",
|
||||
},
|
||||
"TLSv1.3": {},
|
||||
}
|
||||
"""Access dictionary by TLS version that translates from cipher suites IANI (key)
|
||||
to OpenSSL name (value)."""
|
||||
@@ -0,0 +1,214 @@
|
||||
# Copyright (c) 2023, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""
|
||||
Type hint aliases hub.
|
||||
"""
|
||||
import os
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from time import struct_time
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Deque,
|
||||
Dict,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
TypedDict,
|
||||
Union,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .custom_types import HexLiteral
|
||||
|
||||
|
||||
class MySQLScriptPartition(TypedDict):
|
||||
"""Represents a partition or sub-script."""
|
||||
|
||||
mappable_stmt: bytes
|
||||
single_stmts: Deque[bytes]
|
||||
|
||||
|
||||
StrOrBytes = Union[str, bytes]
|
||||
"""Shortcut to for `String or Bytes`."""
|
||||
|
||||
StrOrBytesAny = Union[StrOrBytes, Any]
|
||||
"""Shortcut to for `String or Bytes or Any`."""
|
||||
|
||||
StrOrBytesPath = Union[StrOrBytes, os.PathLike]
|
||||
"""Shortcut to for `String or Bytes or os.PathLike` - this shortcut
|
||||
may come in handy as a hint for path-like variables."""
|
||||
|
||||
PythonProducedType = Union[
|
||||
Decimal,
|
||||
bytes,
|
||||
date,
|
||||
datetime,
|
||||
float,
|
||||
int,
|
||||
Set[str],
|
||||
str,
|
||||
timedelta,
|
||||
None,
|
||||
]
|
||||
"""
|
||||
Python producible types in converter - Types produced after processing a MySQL text
|
||||
result using the built-in converter.
|
||||
"""
|
||||
|
||||
BinaryProtocolType = Union[
|
||||
Decimal,
|
||||
bytes,
|
||||
date,
|
||||
datetime,
|
||||
float,
|
||||
int,
|
||||
str,
|
||||
time,
|
||||
timedelta,
|
||||
None,
|
||||
]
|
||||
"""
|
||||
Supported MySQL Binary Protocol Types - Python type that can be
|
||||
converted to a MySQL type. It's a subset of `MySQLConvertibleType`.
|
||||
"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
MySQLConvertibleType = Union[BinaryProtocolType, Enum, bool, struct_time]
|
||||
"""
|
||||
MySQL convertible Python types - Python types consumed by the built-in converter that
|
||||
can be converted to MySQL. It's a superset of `BinaryProtocolType`.
|
||||
"""
|
||||
|
||||
MySQLProducedType = Optional[Union[int, float, bytes, "HexLiteral"]]
|
||||
"""
|
||||
Types produced after processing MySQL convertible Python types.
|
||||
"""
|
||||
|
||||
HandShakeType = Dict[str, Optional[Union[int, str, bytes]]]
|
||||
"""Dictionary representing the parsed `handshake response`
|
||||
sent at `connection` time by the server."""
|
||||
|
||||
OkPacketType = Dict[str, Optional[Union[int, str]]]
|
||||
"""Dictionary representing the parsed `OK response`
|
||||
produced by the server to signal successful completion of a command."""
|
||||
|
||||
EofPacketType = OkPacketType
|
||||
"""Dictionary representing the parsed `EOF response`
|
||||
produced by the server to signal successful completion of a command.
|
||||
In the MySQL client/server protocol, the EOF and OK responses serve
|
||||
the same purpose, to mark the end of a query execution resul.
|
||||
"""
|
||||
|
||||
DescriptionType = Tuple[
|
||||
str, # field name
|
||||
int, # field type
|
||||
None, # you can ignore it or take a look at protocol.parse_column()
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Union[bool, int], # null ok
|
||||
int, # field flags
|
||||
int, # MySQL charset ID
|
||||
]
|
||||
"""
|
||||
Tuple representing column information.
|
||||
|
||||
Sometimes it can be represented as a 2-Tuple of the form:
|
||||
`Tuple[str, int]` <-> field name, field type.
|
||||
|
||||
However, let's stick with the 9-Tuple format produced by the protocol module.
|
||||
```
|
||||
DescriptionType = Tuple[
|
||||
str, # field name
|
||||
int, # field type
|
||||
None, # you can ignore it or take a look at protocol.parse_column()
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Union[bool, int], # null ok
|
||||
int, # field flags
|
||||
int, # MySQL charset ID
|
||||
]
|
||||
```
|
||||
"""
|
||||
|
||||
StatsPacketType = Dict[str, Union[int, Decimal]]
|
||||
"""Dictionary representing the parsed `Stats response`
|
||||
produced by the server after completing a `COM_STATISTICS` command."""
|
||||
|
||||
ResultType = Mapping[
|
||||
str, Optional[Union[int, str, EofPacketType, List[DescriptionType]]]
|
||||
]
|
||||
"""
|
||||
Represents the returned type by `MySQLConnection._handle_result()`.
|
||||
|
||||
This method can return a dictionary of the form:
|
||||
- columns -> column information
|
||||
- EOF_response -> end-of-file response
|
||||
|
||||
Or, it can return an `OkPacketType`/`EofPacketType`.
|
||||
"""
|
||||
|
||||
RowItemType = Union[PythonProducedType, BinaryProtocolType]
|
||||
"""Item type found in `RowType`."""
|
||||
|
||||
RowType = Tuple[RowItemType, ...]
|
||||
"""Row returned by the MySQL server after sending a query command."""
|
||||
|
||||
CextEofPacketType = Dict[str, int]
|
||||
"""Similar to `EofPacketType` but for the C-EXT."""
|
||||
|
||||
CextResultType = Dict[str, Union[CextEofPacketType, List[DescriptionType]]]
|
||||
"""Similar to `ResultType` but for the C-EXT.
|
||||
|
||||
Represents the returned type by `CMySQLConnection.fetch_eof_columns()`.
|
||||
|
||||
This method returns a dictionary of the form:
|
||||
- columns -> column information
|
||||
- EOF_response -> end-of-file response
|
||||
"""
|
||||
|
||||
ParamsSequenceType = Sequence[MySQLConvertibleType]
|
||||
"""Sequence type expected by `cursor.execute()`."""
|
||||
|
||||
ParamsDictType = Dict[str, MySQLConvertibleType]
|
||||
"""Dictionary type expected by `cursor.execute()`."""
|
||||
|
||||
ParamsSequenceOrDictType = Union[ParamsDictType, ParamsSequenceType]
|
||||
"""Shortcut for `ParamsSequenceType or ParamsDictType`."""
|
||||
|
||||
WarningType = Tuple[str, int, str]
|
||||
"""Warning generated by the previously executed operation."""
|
||||
@@ -0,0 +1,760 @@
|
||||
# Copyright (c) 2009, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""Utilities."""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import platform
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
import warnings
|
||||
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from stringprep import (
|
||||
in_table_a1,
|
||||
in_table_b1,
|
||||
in_table_c3,
|
||||
in_table_c4,
|
||||
in_table_c5,
|
||||
in_table_c6,
|
||||
in_table_c7,
|
||||
in_table_c8,
|
||||
in_table_c9,
|
||||
in_table_c11,
|
||||
in_table_c12,
|
||||
in_table_c21_c22,
|
||||
in_table_d1,
|
||||
in_table_d2,
|
||||
)
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
Union,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mysql.connector.abstracts import MySQLConnectionAbstract
|
||||
|
||||
from .custom_types import HexLiteral
|
||||
from .tls_ciphers import DEPRECATED_TLS_CIPHERSUITES, DEPRECATED_TLS_VERSIONS
|
||||
from .types import StrOrBytes
|
||||
|
||||
__MYSQL_DEBUG__: bool = False
|
||||
|
||||
NUMERIC_TYPES: Tuple[Type[int], Type[float], Type[Decimal], Type[HexLiteral]] = (
|
||||
int,
|
||||
float,
|
||||
Decimal,
|
||||
HexLiteral,
|
||||
)
|
||||
|
||||
|
||||
def intread(buf: Union[int, bytes]) -> int:
|
||||
"""Unpacks the given buffer to an integer"""
|
||||
if isinstance(buf, int):
|
||||
return buf
|
||||
length, tmp = len(buf), bytearray()
|
||||
if length == 1:
|
||||
return buf[0]
|
||||
if length <= 4:
|
||||
tmp += buf + b"\x00" * (4 - length)
|
||||
return int(struct.unpack("<I", tmp)[0])
|
||||
tmp += buf + b"\x00" * (8 - length)
|
||||
return int(struct.unpack("<Q", tmp)[0])
|
||||
|
||||
|
||||
def int1store(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned byte (1 byte) and packs it as a bytes-object.
|
||||
|
||||
Returns string.
|
||||
"""
|
||||
if i < 0 or i > 255:
|
||||
raise ValueError("int1store requires 0 <= i <= 255")
|
||||
return struct.pack("<B", i)
|
||||
|
||||
|
||||
def int2store(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned short (2 bytes) and packs it as a bytes-object.
|
||||
|
||||
Returns string.
|
||||
"""
|
||||
if i < 0 or i > 65535:
|
||||
raise ValueError("int2store requires 0 <= i <= 65535")
|
||||
return struct.pack("<H", i)
|
||||
|
||||
|
||||
def int3store(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned integer (3 bytes) and packs it as a bytes-object.
|
||||
|
||||
Returns string.
|
||||
"""
|
||||
if i < 0 or i > 16777215:
|
||||
raise ValueError("int3store requires 0 <= i <= 16777215")
|
||||
return struct.pack("<I", i)[0:3]
|
||||
|
||||
|
||||
def int4store(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned integer (4 bytes) and packs it as a bytes-object.
|
||||
|
||||
Returns string.
|
||||
"""
|
||||
if i < 0 or i > 4294967295:
|
||||
raise ValueError("int4store requires 0 <= i <= 4294967295")
|
||||
return struct.pack("<I", i)
|
||||
|
||||
|
||||
def int8store(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned integer (8 bytes) and packs it as string.
|
||||
|
||||
Returns string.
|
||||
"""
|
||||
if i < 0 or i > 18446744073709551616:
|
||||
raise ValueError("int8store requires 0 <= i <= 2^64")
|
||||
return struct.pack("<Q", i)
|
||||
|
||||
|
||||
def intstore(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned integers and packs it as a bytes-object.
|
||||
|
||||
This function uses int1store, int2store, int3store,
|
||||
int4store or int8store depending on the integer value.
|
||||
|
||||
returns string.
|
||||
"""
|
||||
if i < 0 or i > 18446744073709551616:
|
||||
raise ValueError("intstore requires 0 <= i <= 2^64")
|
||||
|
||||
if i <= 255:
|
||||
formed_string = int1store
|
||||
elif i <= 65535:
|
||||
formed_string = int2store
|
||||
elif i <= 16777215:
|
||||
formed_string = int3store
|
||||
elif i <= 4294967295:
|
||||
formed_string = int4store
|
||||
else:
|
||||
formed_string = int8store
|
||||
|
||||
return formed_string(i)
|
||||
|
||||
|
||||
def lc_int(i: int) -> bytes:
|
||||
"""
|
||||
Takes an unsigned integer and packs it as bytes,
|
||||
with the information of how much bytes the encoded int takes.
|
||||
"""
|
||||
if i < 0 or i > 18446744073709551616:
|
||||
raise ValueError("Requires 0 <= i <= 2^64")
|
||||
|
||||
if i < 251:
|
||||
return struct.pack("<B", i)
|
||||
if i <= 65535:
|
||||
return b"\xfc" + struct.pack("<H", i)
|
||||
if i <= 16777215:
|
||||
return b"\xfd" + struct.pack("<I", i)[0:3]
|
||||
|
||||
return b"\xfe" + struct.pack("<Q", i)
|
||||
|
||||
|
||||
def read_bytes(buf: bytes, size: int) -> Tuple[bytes, bytes]:
|
||||
"""
|
||||
Reads bytes from a buffer.
|
||||
|
||||
Returns a tuple with buffer less the read bytes, and the bytes.
|
||||
"""
|
||||
res = buf[0:size]
|
||||
return (buf[size:], res)
|
||||
|
||||
|
||||
def read_lc_string(buf: bytes) -> Tuple[bytes, Optional[bytes]]:
|
||||
"""
|
||||
Takes a buffer and reads a length coded string from the start.
|
||||
|
||||
This is how Length coded strings work
|
||||
|
||||
If the string is 250 bytes long or smaller, then it looks like this:
|
||||
|
||||
<-- 1b -->
|
||||
+----------+-------------------------
|
||||
| length | a string goes here
|
||||
+----------+-------------------------
|
||||
|
||||
If the string is bigger than 250, then it looks like this:
|
||||
|
||||
<- 1b -><- 2/3/8 ->
|
||||
+------+-----------+-------------------------
|
||||
| type | length | a string goes here
|
||||
+------+-----------+-------------------------
|
||||
|
||||
if type == \xfc:
|
||||
length is code in next 2 bytes
|
||||
elif type == \xfd:
|
||||
length is code in next 3 bytes
|
||||
elif type == \xfe:
|
||||
length is code in next 8 bytes
|
||||
|
||||
NULL has a special value. If the buffer starts with \xfb then
|
||||
it's a NULL and we return None as value.
|
||||
|
||||
Returns a tuple (trucated buffer, bytes).
|
||||
"""
|
||||
if buf[0] == 251: # \xfb
|
||||
# NULL value
|
||||
return (buf[1:], None)
|
||||
|
||||
length = lsize = 0
|
||||
fst = buf[0]
|
||||
|
||||
if fst <= 250: # \xFA
|
||||
length = fst
|
||||
return (buf[1 + length :], buf[1 : length + 1])
|
||||
if fst == 252:
|
||||
lsize = 2
|
||||
elif fst == 253:
|
||||
lsize = 3
|
||||
elif fst == 254:
|
||||
lsize = 8
|
||||
|
||||
length = intread(buf[1 : lsize + 1])
|
||||
return (buf[lsize + length + 1 :], buf[lsize + 1 : length + lsize + 1])
|
||||
|
||||
|
||||
def read_lc_string_list(buf: bytes) -> Optional[Tuple[Optional[bytes], ...]]:
|
||||
"""Reads all length encoded strings from the given buffer
|
||||
|
||||
Returns a list of bytes
|
||||
"""
|
||||
byteslst: List[Optional[bytes]] = []
|
||||
|
||||
sizes = {252: 2, 253: 3, 254: 8}
|
||||
|
||||
buf_len = len(buf)
|
||||
pos = 0
|
||||
|
||||
while pos < buf_len:
|
||||
first = buf[pos]
|
||||
if first == 255:
|
||||
# Special case when MySQL error 1317 is returned by MySQL.
|
||||
# We simply return None.
|
||||
return None
|
||||
if first == 251:
|
||||
# NULL value
|
||||
byteslst.append(None)
|
||||
pos += 1
|
||||
else:
|
||||
if first <= 250:
|
||||
length = first
|
||||
byteslst.append(buf[(pos + 1) : length + (pos + 1)])
|
||||
pos += 1 + length
|
||||
else:
|
||||
lsize = 0
|
||||
try:
|
||||
lsize = sizes[first]
|
||||
except KeyError:
|
||||
return None
|
||||
length = intread(buf[(pos + 1) : lsize + (pos + 1)])
|
||||
byteslst.append(buf[pos + 1 + lsize : length + lsize + (pos + 1)])
|
||||
pos += 1 + lsize + length
|
||||
|
||||
return tuple(byteslst)
|
||||
|
||||
|
||||
def read_string(
|
||||
buf: bytes,
|
||||
end: Optional[bytes] = None,
|
||||
size: Optional[int] = None,
|
||||
) -> Tuple[bytes, bytes]:
|
||||
"""
|
||||
Reads a string up until a character or for a given size.
|
||||
|
||||
Returns a tuple (trucated buffer, string).
|
||||
"""
|
||||
if end is None and size is None:
|
||||
raise ValueError("read_string() needs either end or size")
|
||||
|
||||
if end is not None:
|
||||
try:
|
||||
idx = buf.index(end)
|
||||
except ValueError as err:
|
||||
raise ValueError("end byte not present in buffer") from err
|
||||
return (buf[idx + 1 :], buf[0:idx])
|
||||
if size is not None:
|
||||
return read_bytes(buf, size)
|
||||
|
||||
raise ValueError("read_string() needs either end or size (weird)")
|
||||
|
||||
|
||||
def read_int(buf: bytes, size: int) -> Tuple[bytes, int]:
|
||||
"""Read an integer from buffer
|
||||
|
||||
Returns a tuple (truncated buffer, int)
|
||||
"""
|
||||
res = intread(buf[0:size])
|
||||
return (buf[size:], res)
|
||||
|
||||
|
||||
def read_lc_int(buf: bytes) -> Tuple[bytes, Optional[int]]:
|
||||
"""
|
||||
Takes a buffer and reads an length code string from the start.
|
||||
|
||||
Returns a tuple with buffer less the integer and the integer read.
|
||||
"""
|
||||
if not buf:
|
||||
raise ValueError("Empty buffer.")
|
||||
|
||||
lcbyte = buf[0]
|
||||
if lcbyte == 251:
|
||||
return (buf[1:], None)
|
||||
if lcbyte < 251:
|
||||
return (buf[1:], int(lcbyte))
|
||||
if lcbyte == 252:
|
||||
return (buf[3:], struct.unpack("<xH", buf[0:3])[0])
|
||||
if lcbyte == 253:
|
||||
return (buf[4:], struct.unpack("<I", buf[1:4] + b"\x00")[0])
|
||||
if lcbyte == 254:
|
||||
return (buf[9:], struct.unpack("<xQ", buf[0:9])[0])
|
||||
raise ValueError("Failed reading length encoded integer")
|
||||
|
||||
|
||||
#
|
||||
# For debugging
|
||||
#
|
||||
def _digest_buffer(buf: StrOrBytes) -> str:
|
||||
"""Debug function for showing buffers"""
|
||||
if not isinstance(buf, str):
|
||||
return "".join([f"\\x{c:02x}" for c in buf])
|
||||
return "".join([f"\\x{ord(c):02x}" for c in buf])
|
||||
|
||||
|
||||
def print_buffer(
|
||||
abuffer: StrOrBytes, prefix: Optional[str] = None, limit: int = 30
|
||||
) -> None:
|
||||
"""Debug function printing output of _digest_buffer()"""
|
||||
if prefix:
|
||||
if limit and limit > 0:
|
||||
digest = _digest_buffer(abuffer[0:limit])
|
||||
else:
|
||||
digest = _digest_buffer(abuffer)
|
||||
print(prefix + ": " + digest)
|
||||
else:
|
||||
print(_digest_buffer(abuffer))
|
||||
|
||||
|
||||
def _parse_os_release() -> Dict[str, str]:
|
||||
"""Parse the contents of /etc/os-release file.
|
||||
|
||||
Returns:
|
||||
A dictionary containing release information.
|
||||
"""
|
||||
distro: Dict[str, str] = {}
|
||||
os_release_file = os.path.join("/etc", "os-release")
|
||||
if not os.path.exists(os_release_file):
|
||||
return distro
|
||||
with open(os_release_file, encoding="utf-8") as file_obj:
|
||||
for line in file_obj:
|
||||
key_value = line.split("=")
|
||||
if len(key_value) != 2:
|
||||
continue
|
||||
key = key_value[0].lower()
|
||||
value = key_value[1].rstrip("\n").strip('"')
|
||||
distro[key] = value
|
||||
return distro
|
||||
|
||||
|
||||
def _parse_lsb_release() -> Dict[str, str]:
|
||||
"""Parse the contents of /etc/lsb-release file.
|
||||
|
||||
Returns:
|
||||
A dictionary containing release information.
|
||||
"""
|
||||
distro = {}
|
||||
lsb_release_file = os.path.join("/etc", "lsb-release")
|
||||
if os.path.exists(lsb_release_file):
|
||||
with open(lsb_release_file, encoding="utf-8") as file_obj:
|
||||
for line in file_obj:
|
||||
key_value = line.split("=")
|
||||
if len(key_value) != 2:
|
||||
continue
|
||||
key = key_value[0].lower()
|
||||
value = key_value[1].rstrip("\n").strip('"')
|
||||
distro[key] = value
|
||||
return distro
|
||||
|
||||
|
||||
def _parse_lsb_release_command() -> Optional[Dict[str, str]]:
|
||||
"""Parse the output of the lsb_release command.
|
||||
|
||||
Returns:
|
||||
A dictionary containing release information.
|
||||
"""
|
||||
distro = {}
|
||||
with open(os.devnull, "w", encoding="utf-8") as devnull:
|
||||
try:
|
||||
stdout = subprocess.check_output(("lsb_release", "-a"), stderr=devnull)
|
||||
except OSError:
|
||||
return None
|
||||
lines = stdout.decode(sys.getfilesystemencoding()).splitlines()
|
||||
for line in lines:
|
||||
key_value = line.split(":")
|
||||
if len(key_value) != 2:
|
||||
continue
|
||||
key = key_value[0].replace(" ", "_").lower()
|
||||
value = key_value[1].strip("\t")
|
||||
distro[key] = value
|
||||
return distro
|
||||
|
||||
|
||||
def linux_distribution() -> Tuple[str, str, str]:
|
||||
"""Tries to determine the name of the Linux OS distribution name.
|
||||
|
||||
First tries to get information from ``/etc/os-release`` file.
|
||||
If fails, tries to get the information of ``/etc/lsb-release`` file.
|
||||
And finally the information of ``lsb-release`` command.
|
||||
|
||||
Returns:
|
||||
A tuple with (`name`, `version`, `codename`)
|
||||
"""
|
||||
distro: Optional[Dict[str, str]] = _parse_lsb_release()
|
||||
if distro:
|
||||
return (
|
||||
distro.get("distrib_id", ""),
|
||||
distro.get("distrib_release", ""),
|
||||
distro.get("distrib_codename", ""),
|
||||
)
|
||||
|
||||
distro = _parse_lsb_release_command()
|
||||
if distro:
|
||||
return (
|
||||
distro.get("distributor_id", ""),
|
||||
distro.get("release", ""),
|
||||
distro.get("codename", ""),
|
||||
)
|
||||
|
||||
distro = _parse_os_release()
|
||||
if distro:
|
||||
return (
|
||||
distro.get("name", ""),
|
||||
distro.get("version_id", ""),
|
||||
distro.get("version_codename", ""),
|
||||
)
|
||||
|
||||
return ("", "", "")
|
||||
|
||||
|
||||
def _get_unicode_read_direction(unicode_str: str) -> str:
|
||||
"""Get the readiness direction of the unicode string.
|
||||
|
||||
We assume that the direction is "L-to-R" if the first character does not
|
||||
indicate the direction is "R-to-L" or an "AL" (Arabic Letter).
|
||||
"""
|
||||
if unicode_str and unicodedata.bidirectional(unicode_str[0]) in (
|
||||
"R",
|
||||
"AL",
|
||||
):
|
||||
return "R-to-L"
|
||||
return "L-to-R"
|
||||
|
||||
|
||||
def _get_unicode_direction_rule(unicode_str: str) -> Dict[str, Callable[[str], bool]]:
|
||||
"""
|
||||
1) The characters in section 5.8 MUST be prohibited.
|
||||
|
||||
2) If a string contains any RandALCat character, the string MUST NOT
|
||||
contain any LCat character.
|
||||
|
||||
3) If a string contains any RandALCat character, a RandALCat
|
||||
character MUST be the first character of the string, and a
|
||||
RandALCat character MUST be the last character of the string.
|
||||
"""
|
||||
read_dir = _get_unicode_read_direction(unicode_str)
|
||||
|
||||
# point 3)
|
||||
if read_dir == "R-to-L":
|
||||
if not (in_table_d1(unicode_str[0]) and in_table_d1(unicode_str[-1])):
|
||||
raise ValueError(
|
||||
"Invalid unicode Bidirectional sequence, if the "
|
||||
"first character is RandALCat, the final character"
|
||||
"must be RandALCat too."
|
||||
)
|
||||
# characters from in_table_d2 are prohibited.
|
||||
return {"Bidirectional Characters requirement 2 [StringPrep, d2]": in_table_d2}
|
||||
|
||||
# characters from in_table_d1 are prohibited.
|
||||
return {"Bidirectional Characters requirement 2 [StringPrep, d2]": in_table_d1}
|
||||
|
||||
|
||||
def validate_normalized_unicode_string(
|
||||
normalized_str: str,
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""Check for Prohibited Output according to rfc4013 profile.
|
||||
|
||||
This profile specifies the following characters as prohibited input:
|
||||
|
||||
- Non-ASCII space characters [StringPrep, C.1.2]
|
||||
- ASCII control characters [StringPrep, C.2.1]
|
||||
- Non-ASCII control characters [StringPrep, C.2.2]
|
||||
- Private Use characters [StringPrep, C.3]
|
||||
- Non-character code points [StringPrep, C.4]
|
||||
- Surrogate code points [StringPrep, C.5]
|
||||
- Inappropriate for plain text characters [StringPrep, C.6]
|
||||
- Inappropriate for canonical representation characters [StringPrep, C.7]
|
||||
- Change display properties or deprecated characters [StringPrep, C.8]
|
||||
- Tagging characters [StringPrep, C.9]
|
||||
|
||||
In addition of checking of Bidirectional Characters [StringPrep, Section 6]
|
||||
and the Unassigned Code Points [StringPrep, A.1].
|
||||
|
||||
Returns:
|
||||
A tuple with ("probited character", "breaked_rule")
|
||||
"""
|
||||
rules = {
|
||||
"Space characters that contains the ASCII code points": in_table_c11,
|
||||
"Space characters non-ASCII code points": in_table_c12,
|
||||
"Unassigned Code Points [StringPrep, A.1]": in_table_a1,
|
||||
"Non-ASCII space characters [StringPrep, C.1.2]": in_table_c12,
|
||||
"ASCII control characters [StringPrep, C.2.1]": in_table_c21_c22,
|
||||
"Private Use characters [StringPrep, C.3]": in_table_c3,
|
||||
"Non-character code points [StringPrep, C.4]": in_table_c4,
|
||||
"Surrogate code points [StringPrep, C.5]": in_table_c5,
|
||||
"Inappropriate for plain text characters [StringPrep, C.6]": in_table_c6,
|
||||
"Inappropriate for canonical representation characters [StringPrep, C.7]": in_table_c7,
|
||||
"Change display properties or deprecated characters [StringPrep, C.8]": in_table_c8,
|
||||
"Tagging characters [StringPrep, C.9]": in_table_c9,
|
||||
}
|
||||
|
||||
try:
|
||||
rules.update(_get_unicode_direction_rule(normalized_str))
|
||||
except ValueError as err:
|
||||
return normalized_str, str(err)
|
||||
|
||||
for char in normalized_str:
|
||||
for rule, func in rules.items():
|
||||
if func(char) and char != " ":
|
||||
return char, rule
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def normalize_unicode_string(a_string: str) -> str:
|
||||
"""normalizes a unicode string according to rfc4013
|
||||
|
||||
Normalization of a unicode string according to rfc4013: The SASLprep profile
|
||||
of the "stringprep" algorithm.
|
||||
|
||||
Normalization Unicode equivalence is the specification by the Unicode
|
||||
character encoding standard that some sequences of code points represent
|
||||
essentially the same character.
|
||||
|
||||
This method normalizes using the Normalization Form Compatibility
|
||||
Composition (NFKC), as described in rfc4013 2.2.
|
||||
|
||||
Returns:
|
||||
Normalized unicode string according to rfc4013.
|
||||
"""
|
||||
# Per rfc4013 2.1. Mapping
|
||||
# non-ASCII space characters [StringPrep, C.1.2] are mapped to ' ' (U+0020)
|
||||
# "commonly mapped to nothing" characters [StringPrep, B.1] are mapped to ''
|
||||
nstr_list = [
|
||||
" " if in_table_c12(char) else "" if in_table_b1(char) else char
|
||||
for char in a_string
|
||||
]
|
||||
|
||||
nstr = "".join(nstr_list)
|
||||
|
||||
# Per rfc4013 2.2. Use NFKC Normalization Form Compatibility Composition
|
||||
# Characters are decomposed by compatibility, then recomposed by canonical
|
||||
# equivalence.
|
||||
nstr = unicodedata.normalize("NFKC", nstr)
|
||||
if not nstr:
|
||||
# Normilization results in empty string.
|
||||
return ""
|
||||
|
||||
return nstr
|
||||
|
||||
|
||||
def init_bytearray(
|
||||
payload: Union[int, StrOrBytes] = b"", encoding: str = "utf-8"
|
||||
) -> bytearray:
|
||||
"""Initialize a bytearray from the payload."""
|
||||
if isinstance(payload, bytearray):
|
||||
return payload
|
||||
if isinstance(payload, int):
|
||||
return bytearray(payload)
|
||||
if not isinstance(payload, bytes):
|
||||
try:
|
||||
return bytearray(payload.encode(encoding=encoding))
|
||||
except AttributeError as err:
|
||||
raise ValueError("payload must be a str or bytes") from err
|
||||
|
||||
return bytearray(payload)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_platform() -> Dict[str, Union[str, Tuple[str, str]]]:
|
||||
"""Return a dict with the platform arch and OS version."""
|
||||
plat: Dict[str, Union[str, Tuple[str, str]]] = {"arch": "", "version": ""}
|
||||
if os.name == "nt":
|
||||
if "64" in platform.architecture()[0]:
|
||||
plat["arch"] = "x86_64"
|
||||
elif "32" in platform.architecture()[0]:
|
||||
plat["arch"] = "i386"
|
||||
else:
|
||||
plat["arch"] = platform.architecture()
|
||||
plat["version"] = f"Windows-{platform.win32_ver()[1]}"
|
||||
else:
|
||||
plat["arch"] = platform.machine()
|
||||
if platform.system() == "Darwin":
|
||||
plat["version"] = f"macOS-{platform.mac_ver()[0]}"
|
||||
else:
|
||||
plat["version"] = "-".join(linux_distribution()[0:2])
|
||||
|
||||
return plat
|
||||
|
||||
|
||||
def import_object(fullpath: str) -> Any:
|
||||
"""Import an object from a fully qualified module path.
|
||||
|
||||
Args:
|
||||
obj (str): A string representing the fully qualified name of the object.
|
||||
|
||||
Returns:
|
||||
Object: The imported object.
|
||||
|
||||
Raises:
|
||||
ValueError: If the object can't be imported.
|
||||
|
||||
.. versionadded:: 8.0.33
|
||||
"""
|
||||
if not isinstance(fullpath, str):
|
||||
raise ValueError(
|
||||
"'fullpath' should be a str representing the fully qualified name of the "
|
||||
"object to be imported"
|
||||
)
|
||||
try:
|
||||
module_str, callable_str = fullpath.rsplit(".", 1)
|
||||
module = importlib.import_module(module_str)
|
||||
obj = getattr(module, callable_str)
|
||||
except ValueError:
|
||||
raise ValueError(f"No callable named '{fullpath}'") from None
|
||||
except (AttributeError, ModuleNotFoundError) as err:
|
||||
raise ValueError(f"{err}") from err
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def warn_ciphersuites_deprecated(cipher_as_ossl: str, tls_version: str) -> None:
|
||||
"""Emits a warning if a deprecated cipher is being utilized.
|
||||
|
||||
Args:
|
||||
cipher: Must be ingested as OpenSSL name.
|
||||
tls_versions: TLS version to check the cipher against.
|
||||
|
||||
Raises:
|
||||
DeprecationWarning: If the cipher is flagged as deprecated
|
||||
according to the OSSA cipher list.
|
||||
"""
|
||||
if cipher_as_ossl in DEPRECATED_TLS_CIPHERSUITES.get(tls_version, {}).values():
|
||||
warn_msg = (
|
||||
f"This connection is using TLS cipher {cipher_as_ossl} which is now "
|
||||
"deprecated and will be removed in a future release of "
|
||||
"MySQL Connector/Python."
|
||||
)
|
||||
warnings.warn(warn_msg, DeprecationWarning)
|
||||
|
||||
|
||||
def warn_tls_version_deprecated(tls_version: str) -> None:
|
||||
"""Emits a warning if a deprecated TLS version is being utilized.
|
||||
|
||||
Args:
|
||||
tls_versions: TLS version to check.
|
||||
|
||||
Raises:
|
||||
DeprecationWarning: If the TLS version is flagged as deprecated
|
||||
according to the OSSA cipher list.
|
||||
"""
|
||||
if tls_version in DEPRECATED_TLS_VERSIONS:
|
||||
warn_msg = (
|
||||
f"This connection is using TLS version {tls_version} which is now "
|
||||
"deprecated and will be removed in a future release of "
|
||||
"MySQL Connector/Python."
|
||||
)
|
||||
warnings.warn(warn_msg, DeprecationWarning)
|
||||
|
||||
|
||||
class GenericWrapper:
|
||||
"""Base class that provides basic object wrapper functionality."""
|
||||
|
||||
def __init__(self, wrapped: Any) -> None:
|
||||
"""Constructor."""
|
||||
self._wrapped: Any = wrapped
|
||||
|
||||
def __getattr__(self, attr: str) -> Any:
|
||||
"""Gets an attribute.
|
||||
|
||||
Attributes defined in the wrapper object have higher precedence
|
||||
than those wrapped object equivalent. Attributes not found in
|
||||
the wrapper are then searched in the wrapped object.
|
||||
"""
|
||||
if attr in self.__dict__:
|
||||
# this object has it
|
||||
return getattr(self, attr)
|
||||
# proxy to the wrapped object
|
||||
return getattr(self._wrapped, attr)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
"""Sets an attribute."""
|
||||
if "_wrapped" not in self.__dict__:
|
||||
self.__dict__["_wrapped"] = value
|
||||
return
|
||||
|
||||
if name in self.__dict__:
|
||||
# this object has it
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
# proxy to the wrapped object
|
||||
self._wrapped.__setattr__(name, value)
|
||||
|
||||
def get_wrapped_class(self) -> str:
|
||||
"""Gets the wrapped class name."""
|
||||
return self._wrapped.__class__.__name__
|
||||
@@ -0,0 +1,46 @@
|
||||
# Copyright (c) 2012, 2026, Oracle and/or its affiliates.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License, version 2.0, as
|
||||
# published by the Free Software Foundation.
|
||||
#
|
||||
# This program is designed to work with certain software (including
|
||||
# but not limited to OpenSSL) that is licensed under separate terms,
|
||||
# as designated in a particular file or component or in included license
|
||||
# documentation. The authors of MySQL hereby grant you an
|
||||
# additional permission to link the program and your derivative works
|
||||
# with the separately licensed software that they have either included with
|
||||
# the program or referenced in the documentation.
|
||||
#
|
||||
# Without limiting anything contained in the foregoing, this file,
|
||||
# which is part of MySQL Connector/Python, is also subject to the
|
||||
# Universal FOSS Exception, version 1.0, a copy of which can be found at
|
||||
# http://oss.oracle.com/licenses/universal-foss-exception.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License, version 2.0, for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
"""MySQL Connector/Python version information
|
||||
|
||||
The file version.py gets installed and is available after installation
|
||||
as mysql.connector.version.
|
||||
"""
|
||||
|
||||
VERSION = (9, 7, 0, "", 1)
|
||||
|
||||
# pylint: disable=consider-using-f-string
|
||||
if VERSION[3] and VERSION[4]:
|
||||
VERSION_TEXT = "{0}.{1}.{2}{3}{4}".format(*VERSION)
|
||||
else:
|
||||
VERSION_TEXT = "{0}.{1}.{2}".format(*VERSION[0:3])
|
||||
# pylint: enable=consider-using-f-string
|
||||
|
||||
VERSION_EXTRA = ""
|
||||
LICENSE = "GPLv2 with FOSS License Exception"
|
||||
EDITION = "" # Added in package names, after the version
|
||||
Reference in New Issue
Block a user