SolarManager unter Versionsverwaltung

Erster Stand der Hintergrundprozesse, die auf der Synology unter
/volume1/homes/wagner/SolarManager laufen: der Manager selbst, die Sammler
je Geraet, die MQTT-Bruecke, der Wecker und - neu hinzugezogen - der
AutoAction-Runner, der als Hintergrundprozess hierher gehoert und nicht ins
Web-Verzeichnis.

Zugangsdaten stehen nicht mehr im Quelltext, sondern in config.ini, die
nicht mit eingecheckt wird. Vorlage ist config.ini.example, gelesen wird sie
von konfig.py. Betroffen waren solarManager.py (Datenbank und Wattpilot),
zeit.py, gatherWaterData.py, wecker.py und skoda_testdaten.py, das sich das
Passwort bisher aus dem Quelltext eines anderen Moduls herausgesucht hat.

Die Kia-Anbindung ist mit dem Fahrzeug entfallen: kiaTest.py,
gatherCarData.py und hyundai_kia_connect_api sind nicht mehr dabei, ebenso
gatherInverterData.py, auf das nur noch eine auskommentierte Zeile zeigte.

Die mitgelieferten Bibliotheken bleiben im Repository - die NAS hat kein
pip, sie muessen neben den Skripten liegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 20:46:59 +02:00
co-authored by Claude Opus 5
commit 79843aa2ae
968 changed files with 261182 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
# Copyright (c) 2009, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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
__version__ = version.VERSION_TEXT
__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",
]
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
# Copyright (c) 2014, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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"""
import importlib
from functools import lru_cache
from typing import Optional, Type
from .errors import NotSupportedError, ProgrammingError
from .logger import logger
from .plugins import BaseAuthPlugin
DEFAULT_PLUGINS_PKG = "mysql.connector.plugins"
@lru_cache(maxsize=10, typed=False)
def get_auth_plugin(
plugin_name: str,
auth_plugin_class: Optional[str] = None,
) -> Type[BaseAuthPlugin]: # AUTH_PLUGIN_CLASS_TYPES:
"""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 `BaseAuthPlugin`.
"""
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")
+620
View File
@@ -0,0 +1,620 @@
# -*- coding: utf-8 -*- # pylint: disable=missing-module-docstring
# Copyright (c) 2013, 2022, Oracle and/or its affiliates. All rights reserved.
#
# 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 also distributed 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 included with
# MySQL.
#
# 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
+740
View File
@@ -0,0 +1,740 @@
# Copyright (c) 2009, 2023, 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 also distributed 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 included with
# MySQL.
#
# 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 datetime
import math
import struct
import time
from decimal import Decimal
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
from .constants import CharacterSet, FieldFlag, FieldType
from .custom_types import HexLiteral
from .types import (
DescriptionType,
RowType,
StrOrBytes,
ToMysqlInputTypes,
ToMysqlOutputTypes,
ToPythonOutputTypes,
)
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.python_types: Optional[Tuple[Any, ...]] = None
self.mysql_types: Optional[Tuple[Any, ...]] = 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], ToPythonOutputTypes],
] = {}
def set_charset(self, charset: Optional[str]) -> 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"
self.charset_id = CharacterSet.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: ToMysqlInputTypes
) -> Union[ToMysqlInputTypes, HexLiteral]:
"""Convert Python data type to MySQL"""
type_name = value.__class__.__name__.lower()
try:
converted: ToMysqlOutputTypes = getattr(self, f"_{type_name}_to_mysql")(
value
)
return converted
except AttributeError:
return value
def to_python(
self, vtype: DescriptionType, value: Optional[bytes]
) -> ToPythonOutputTypes:
"""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[str] = 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:
MySQLConverterBase.__init__(self, charset, use_unicode, str_fallback)
self._cache_field_types: Dict[
int,
Callable[[bytes, DescriptionType], ToPythonOutputTypes],
] = {}
@staticmethod
def escape(value: Any, sql_mode: Optional[str] = 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(value, (bytes, bytearray)):
if sql_mode == "NO_BACKSLASH_ESCAPES":
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 == "NO_BACKSLASH_ESCAPES":
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: ToMysqlInputTypes) -> ToMysqlOutputTypes:
"""Convert Python data type to MySQL"""
type_name = value.__class__.__name__.lower()
try:
converted: ToMysqlOutputTypes = getattr(self, f"_{type_name}_to_mysql")(
value
)
return converted
except AttributeError:
if self.str_fallback:
return str(value).encode()
raise TypeError(
f"Python '{type_name}' cannot be converted to a MySQL type"
) from None
def to_python(
self,
vtype: DescriptionType,
value: Optional[bytes],
) -> ToPythonOutputTypes:
"""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 = CharacterSet.get_charset_info(charset)[0]
encoded = value.encode(charset)
if charset_id in CharacterSet.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]
) -> RowType:
"""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[ToPythonOutputTypes] = [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)
_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
+50
View File
@@ -0,0 +1,50 @@
# Copyright (c) 2014, 2022, Oracle and/or its affiliates. All rights reserved.
#
# 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 also distributed 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 included with
# MySQL.
#
# 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
+85
View File
@@ -0,0 +1,85 @@
# Copyright (c) 2009, 2022, Oracle and/or its affiliates. All rights reserved.
#
# 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 also distributed 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 included with
# MySQL.
#
# 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"
threadsafety: int = 1
paramstyle: str = "pyformat"
import datetime
import time
from typing import Tuple
from . import constants
class _DBAPITypeObject:
def __init__(self, *values: int) -> None:
self.values: Tuple[int, ...] = values
def __eq__(self, other: object) -> bool:
return other in self.values
def __ne__(self, other: object) -> bool:
return other not in self.values
Date = datetime.date
Time = datetime.time
Timestamp = datetime.datetime
def DateFromTicks(ticks: int) -> datetime.date:
"""Construct an object holding a date value from the given ticks value."""
return Date(*time.localtime(ticks)[:3])
def TimeFromTicks(ticks: int) -> datetime.time:
"""Construct an object holding a time value from the given ticks value."""
return Time(*time.localtime(ticks)[3:6])
def TimestampFromTicks(ticks: int) -> datetime.datetime:
"""Construct an object holding a time stamp from the given ticks value."""
return Timestamp(*time.localtime(ticks)[:6])
Binary = bytes
STRING = _DBAPITypeObject(*constants.FieldType.get_string_types())
BINARY = _DBAPITypeObject(*constants.FieldType.get_binary_types())
NUMBER = _DBAPITypeObject(*constants.FieldType.get_number_types())
DATETIME = _DBAPITypeObject(*constants.FieldType.get_timestamp_types())
ROWID = _DBAPITypeObject()
View File
+636
View File
@@ -0,0 +1,636 @@
# Copyright (c) 2020, 2023, 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 also distributed 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 included with
# MySQL.
#
# 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 Any, Dict, Generator, Iterator, List, Optional, 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
try:
import mysql.connector
from mysql.connector.connection import MySQLConnection
from mysql.connector.connection_cext import CMySQLConnection
from mysql.connector.conversion import MySQLConverter
from mysql.connector.cursor import MySQLCursor
from mysql.connector.cursor_cext import CMySQLCursor
from mysql.connector.custom_types import HexLiteral
from mysql.connector.pooling import PooledMySQLConnection
from mysql.connector.types import (
ParamsDictType,
ParamsSequenceOrDictType,
ParamsSequenceType,
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
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)
value = value.astimezone(timezone.utc).replace(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: Union[MySQLCursor, CMySQLCursor]) -> None:
self.cursor: Union[MySQLCursor, CMySQLCursor] = cursor
@staticmethod
def _adapt_execute_args_dict(args: ParamsDictType) -> ParamsDictType:
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[ParamsSequenceType],
) -> Optional[ParamsSequenceType]:
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[ParamsSequenceOrDictType] = None
) -> Optional[Generator[Union[MySQLCursor, CMySQLCursor], 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: Union[
Tuple[ParamsSequenceOrDictType, ...],
List[ParamsSequenceOrDictType],
],
) -> Optional[Generator[Union[MySQLCursor, CMySQLCursor], None, None]]:
"""Executes the given operation
This wrapper method around the executemany()-method of the cursor is
mainly needed to re-raise using different exceptions.
"""
try:
return self.cursor.executemany(query, args)
except mysql.connector.OperationalError as exc:
if exc.args[0] in self.codes_for_integrityerror:
raise IntegrityError(*tuple(exc.args)) from None
raise
def __getattr__(self, attr: Any) -> Any:
"""Return an attribute of wrapped cursor"""
return getattr(self.cursor, attr)
def __iter__(self) -> Iterator[RowType]:
"""Return an iterator over wrapped cursor"""
return iter(self.cursor)
class DatabaseWrapper(BaseDatabaseWrapper): # pylint: disable=abstract-method
"""Represent a database connection."""
vendor = "mysql"
# This dictionary maps Field objects to their associated MySQL column
# types, as strings. Column-type strings can contain format strings; they'll
# be interpolated against the values of Field.__dict__ before being output.
# If a column type is set to None, it won't be included in the output.
data_types = {
"AutoField": "integer AUTO_INCREMENT",
"BigAutoField": "bigint AUTO_INCREMENT",
"BinaryField": "longblob",
"BooleanField": "bool",
"CharField": "varchar(%(max_length)s)",
"DateField": "date",
"DateTimeField": "datetime(6)",
"DecimalField": "numeric(%(max_digits)s, %(decimal_places)s)",
"DurationField": "bigint",
"FileField": "varchar(%(max_length)s)",
"FilePathField": "varchar(%(max_length)s)",
"FloatField": "double precision",
"IntegerField": "integer",
"BigIntegerField": "bigint",
"IPAddressField": "char(15)",
"GenericIPAddressField": "char(39)",
"JSONField": "json",
"NullBooleanField": "bool",
"OneToOneField": "integer",
"PositiveBigIntegerField": "bigint UNSIGNED",
"PositiveIntegerField": "integer UNSIGNED",
"PositiveSmallIntegerField": "smallint UNSIGNED",
"SlugField": "varchar(%(max_length)s)",
"SmallAutoField": "smallint AUTO_INCREMENT",
"SmallIntegerField": "smallint",
"TextField": "longtext",
"TimeField": "time(6)",
"UUIDField": "char(32)",
}
# For these data types:
# - MySQL < 8.0.13 doesn't accept default values and
# implicitly treat them as nullable
# - all versions of MySQL doesn't support full width database
# indexes
_limited_data_types = (
"tinyblob",
"blob",
"mediumblob",
"longblob",
"tinytext",
"text",
"mediumtext",
"longtext",
"json",
)
operators = {
"exact": "= %s",
"iexact": "LIKE %s",
"contains": "LIKE BINARY %s",
"icontains": "LIKE %s",
"regex": "REGEXP BINARY %s",
"iregex": "REGEXP %s",
"gt": "> %s",
"gte": ">= %s",
"lt": "< %s",
"lte": "<= %s",
"startswith": "LIKE BINARY %s",
"endswith": "LIKE BINARY %s",
"istartswith": "LIKE %s",
"iendswith": "LIKE %s",
}
# The patterns below are used to generate SQL pattern lookup clauses when
# the right-hand side of the lookup isn't a raw string (it might be an expression
# or the result of a bilateral transformation).
# In those cases, special characters for LIKE operators (e.g. \, *, _) should be
# escaped on database side.
#
# Note: we use str.format() here for readability as '%' is used as a wildcard for
# the LIKE operator.
pattern_esc = r"REPLACE(REPLACE(REPLACE({}, '\\', '\\\\'), '%%', '\%%'), '_', '\_')"
pattern_ops = {
"contains": "LIKE BINARY CONCAT('%%', {}, '%%')",
"icontains": "LIKE CONCAT('%%', {}, '%%')",
"startswith": "LIKE BINARY CONCAT({}, '%%')",
"istartswith": "LIKE CONCAT({}, '%%')",
"endswith": "LIKE BINARY CONCAT('%%', {})",
"iendswith": "LIKE CONCAT('%%', {})",
}
isolation_level: Optional[str] = None
isolation_levels = {
"read uncommitted",
"read committed",
"repeatable read",
"serializable",
}
Database = mysql.connector
SchemaEditorClass = DatabaseSchemaEditor
# Classes instantiated in __init__().
client_class = DatabaseClient
creation_class = DatabaseCreation
features_class = DatabaseFeatures
introspection_class = DatabaseIntrospection
ops_class = DatabaseOperations
validation_class = DatabaseValidation
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
options = self.settings_dict.get("OPTIONS")
if options:
self._use_pure = options.get("use_pure", not HAVE_CEXT)
converter_class = options.get(
"converter_class",
DjangoMySQLConverter,
)
if not issubclass(converter_class, DjangoMySQLConverter):
raise ProgrammingError(
"Converter class should be a subclass of "
"mysql.connector.django.base.DjangoMySQLConverter"
)
self.converter = converter_class()
else:
self.converter = DjangoMySQLConverter()
self._use_pure = not HAVE_CEXT
def __getattr__(self, attr: str) -> bool:
if attr.startswith("mysql_is"):
return False
raise AttributeError
def get_connection_params(self) -> Dict[str, Any]:
kwargs = {
"charset": "utf8",
"use_unicode": True,
"buffered": False,
"consume_results": True,
}
settings_dict = self.settings_dict
if settings_dict["USER"]:
kwargs["user"] = settings_dict["USER"]
if settings_dict["NAME"]:
kwargs["database"] = settings_dict["NAME"]
if settings_dict["PASSWORD"]:
kwargs["passwd"] = settings_dict["PASSWORD"]
if settings_dict["HOST"].startswith("/"):
kwargs["unix_socket"] = settings_dict["HOST"]
elif settings_dict["HOST"]:
kwargs["host"] = settings_dict["HOST"]
if settings_dict["PORT"]:
kwargs["port"] = int(settings_dict["PORT"])
if settings_dict.get("OPTIONS", {}).get("init_command"):
kwargs["init_command"] = settings_dict["OPTIONS"]["init_command"]
# Raise exceptions for database warnings if DEBUG is on
kwargs["raise_on_warnings"] = settings.DEBUG
kwargs["client_flags"] = [
# Need potentially affected rows on UPDATE
mysql.connector.constants.ClientFlag.FOUND_ROWS,
]
try:
options = settings_dict["OPTIONS"].copy()
isolation_level = options.pop("isolation_level")
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, MySQLConnection, CMySQLConnection]:
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(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
@staticmethod
def display_name() -> str:
"""Display name."""
return "MySQL"
@cached_property
def data_type_check_constraints(self) -> Dict[str, str]:
"""Mapping of Field objects to their SQL for CHECK constraints."""
if self.features.supports_column_check_constraints:
check_constraints = {
"PositiveBigIntegerField": "`%(column)s` >= 0",
"PositiveIntegerField": "`%(column)s` >= 0",
"PositiveSmallIntegerField": "`%(column)s` >= 0",
}
return check_constraints
return {}
@cached_property
def mysql_server_data(self) -> Dict[str, Any]:
"""Return MySQL server data."""
with self.temporary_connection() as cursor:
# Select some server variables and test if the time zone
# definitions are installed. CONVERT_TZ returns NULL if 'UTC'
# timezone isn't loaded into the mysql.time_zone table.
cursor.execute(
"""
SELECT VERSION(),
@@sql_mode,
@@default_storage_engine,
@@sql_auto_is_null,
@@lower_case_table_names,
CONVERT_TZ('2001-01-01 01:00:00', 'UTC', 'UTC') IS NOT NULL
"""
)
row = cursor.fetchone()
return {
"version": row[0],
"sql_mode": row[1],
"default_storage_engine": row[2],
"sql_auto_is_null": bool(row[3]),
"lower_case_table_names": bool(row[4]),
"has_zoneinfo_database": bool(row[5]),
}
@cached_property
def mysql_server_info(self) -> Any:
"""Return MySQL version."""
with self.temporary_connection() as cursor:
cursor.execute("SELECT VERSION()")
return cursor.fetchone()[0]
@cached_property
def mysql_version(self) -> Tuple[int, ...]:
"""Return MySQL version."""
config = self.get_connection_params()
with mysql.connector.connect(**config) as conn:
server_version: Tuple[int, ...] = conn.get_server_version()
return server_version
@cached_property
def sql_mode(self) -> Set[str]:
"""Return SQL mode."""
with self.cursor() as cursor:
cursor.execute("SELECT @@sql_mode")
sql_mode = cursor.fetchone()
return set(sql_mode[0].split(",") if sql_mode else ())
@property
def use_pure(self) -> bool:
"""Return True if pure Python version is being used."""
ans: bool = self._use_pure
return ans
class DjangoMySQLConverter(MySQLConverter):
"""Custom converter for Django."""
# pylint: disable=unused-argument
@staticmethod
def _time_to_python(value: bytes, dsc: Any = None) -> Optional[time]:
"""Return MySQL TIME data type as datetime.time()
Returns datetime.time()
"""
return dateparse.parse_time(value.decode("utf-8"))
@staticmethod
def _datetime_to_python(value: bytes, dsc: Any = None) -> Optional[datetime]:
"""Connector/Python always returns naive datetime.datetime
Connector/Python always returns naive timestamps since MySQL has
no time zone support.
- A naive datetime is a datetime that doesn't know its own timezone.
Django needs a non-naive datetime, but in this method we don't need
to make a datetime value time zone aware since Django itself at some
point will make it aware (at least in versions 3.2.16 and 4.1.2) when
USE_TZ=True. This may change in a future release, we need to keep an
eye on this behaviour.
Returns datetime.datetime()
"""
return MySQLConverter._datetime_to_python(value) if value else None
# pylint: enable=unused-argument
def _safestring_to_mysql(self, value: str) -> Union[bytes, HexLiteral]:
return self._str_to_mysql(value)
def _safetext_to_mysql(self, value: str) -> Union[bytes, HexLiteral]:
return self._str_to_mysql(value)
def _safebytes_to_mysql(self, value: bytes) -> bytes:
return self._bytes_to_mysql(value)
+106
View File
@@ -0,0 +1,106 @@
# Copyright (c) 2020, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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)
+45
View File
@@ -0,0 +1,45 @@
# Copyright (c) 2020, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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",
]
+33
View File
@@ -0,0 +1,33 @@
# Copyright (c) 2020, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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"]
+50
View File
@@ -0,0 +1,50 @@
# Copyright (c) 2020, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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)
+461
View File
@@ -0,0 +1,461 @@
# Copyright (c) 2020, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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
+104
View File
@@ -0,0 +1,104 @@
# Copyright (c) 2020, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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
from typing import Optional
from django.conf import settings
from django.db.backends.mysql.operations import (
DatabaseOperations as MySQLDatabaseOperations,
)
from django.utils import 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 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 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)
+59
View File
@@ -0,0 +1,59 @@
# Copyright (c) 2020, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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
+33
View File
@@ -0,0 +1,33 @@
# Copyright (c) 2020, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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
+336
View File
@@ -0,0 +1,336 @@
# Copyright (c) 2009, 2023, 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 also distributed 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 included with
# MySQL.
#
# 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"""
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"""
ErrorClassTypes = Union[
Type[Error],
Type[InterfaceError],
Type[DatabaseError],
Type[InternalError],
Type[OperationalError],
Type[ProgrammingError],
Type[IntegrityError],
Type[DataError],
Type[NotSupportedError],
Type[PoolError],
]
ErrorTypes = Union[
Error,
InterfaceError,
DatabaseError,
InternalError,
OperationalError,
ProgrammingError,
IntegrityError,
DataError,
NotSupportedError,
PoolError,
Warning,
]
# _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]]:
"""Define custom exceptions for MySQL server errors
This function defines custom exceptions for MySQL server errors and
returns the current set customizations.
If error is a MySQL Server error number, then you have to pass also the
exception class.
The error argument can also be a dictionary in which case the key is
the server error number, and value the exception to be raised.
If none of the arguments are given, then custom_error_exception() will
simply return the current set customizations.
To reset the customizations, simply supply an empty dictionary.
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({})
Returns a dictionary.
"""
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,
}
+80
View File
@@ -0,0 +1,80 @@
# Copyright (c) 2012, 2022, Oracle and/or its affiliates. All rights reserved.
#
# 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 also distributed 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 included with
# MySQL.
#
# 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")
+30
View File
@@ -0,0 +1,30 @@
# Copyright (c) 2012, 2017, Oracle and/or its affiliates. All rights reserved.
#
# 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 also distributed 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 included with
# MySQL.
#
# 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
"""
+152
View File
@@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2013, 2022, Oracle and/or its affiliates. All rights reserved.
#
# 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 also distributed 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 included with
# MySQL.
#
# 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
+33
View File
@@ -0,0 +1,33 @@
# Copyright (c) 2022, 2023, 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 also distributed 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 included with
# MySQL.
#
# 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")
+744
View File
@@ -0,0 +1,744 @@
# Copyright (c) 2012, 2023, 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 also distributed 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 included with
# MySQL.
#
# 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"
"""Module implementing low-level socket communication with MySQL servers.
"""
import os
import socket
import struct
import warnings
import zlib
from abc import ABC, abstractmethod
from collections import deque
try:
import ssl
TLS_VERSIONS = {
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1.1": ssl.PROTOCOL_TLSv1_1,
"TLSv1.2": ssl.PROTOCOL_TLSv1_2,
}
# TLSv1.3 included in PROTOCOL_TLS, but PROTOCOL_TLS is not included on 3.4
TLS_VERSIONS["TLSv1.3"] = (
ssl.PROTOCOL_TLS
if hasattr(ssl, "PROTOCOL_TLS")
else ssl.PROTOCOL_SSLv23 # Alias of 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
from typing import Any, Deque, List, Optional, Tuple, Union
from .errors import InterfaceError, NotSupportedError, OperationalError
from .types import StrOrBytesPath
MIN_COMPRESS_LENGTH: int = 50
MAX_PAYLOAD_LENGTH: int = 2**24 - 1
PACKET_HEADER_LENGTH: int = 4
COMPRESSED_PACKET_HEADER_LENGTH: int = 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"{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 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 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 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,
ca: StrOrBytesPath,
cert: StrOrBytesPath,
key: StrOrBytesPath,
verify_cert: bool = False,
verify_identity: bool = False,
cipher_suites: Optional[str] = None,
tls_versions: Optional[List[str]] = None,
) -> None:
"""Switch the socket to use SSL"""
if not self.sock:
raise InterfaceError(errno=2048)
try:
if verify_cert:
cert_reqs = ssl.CERT_REQUIRED
elif verify_identity:
cert_reqs = ssl.CERT_OPTIONAL
else:
cert_reqs = ssl.CERT_NONE
if tls_versions is None or not tls_versions:
context = ssl.create_default_context()
if not verify_identity:
context.check_hostname = False
else:
tls_versions.sort(reverse=True)
tls_version = tls_versions[0]
if (
not TLS_V1_3_SUPPORTED
and tls_version == "TLSv1.3"
and len(tls_versions) > 1
):
tls_version = tls_versions[1]
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
context.check_hostname = False
context.verify_mode = cert_reqs
context.load_default_certs()
if ca:
try:
context.load_verify_locations(ca)
except (IOError, ssl.SSLError) as err:
self.sock.close()
raise InterfaceError(f"Invalid CA Certificate: {err}") from err
if cert:
try:
context.load_cert_chain(cert, key)
except (IOError, ssl.SSLError) as err:
self.sock.close()
raise InterfaceError(f"Invalid Certificate/Key: {err}") from err
if cipher_suites:
context.set_ciphers(cipher_suites)
if hasattr(self, "server_host"):
self.sock = context.wrap_socket(
self.sock, server_hostname=self.server_host
)
else:
self.sock = context.wrap_socket(self.sock)
if verify_identity:
context.check_hostname = True
hostnames: List[str] = [self.server_host] if self.server_host else []
if os.name == "nt" and self.server_host == "localhost":
hostnames = ["localhost", "127.0.0.1"]
aliases = socket.gethostbyaddr(self.server_host)
hostnames.extend([aliases[0]] + aliases[1])
match_found = False
errs = []
for hostname in hostnames:
try:
# Deprecated in Python 3.7 without a replacement and
# should be removed in the future, since OpenSSL now
# performs hostname matching
# pylint: disable=deprecated-method
ssl.match_hostname(self.sock.getpeercert(), hostname)
# pylint: enable=deprecated-method
except ssl.CertificateError as err:
errs.append(str(err))
else:
match_found = True
break
if not match_found:
self.sock.close()
raise InterfaceError(
f"Unable to verify server identity: {', '.join(errs)}"
)
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 send(
self,
payload: bytes,
packet_number: Optional[int] = None,
compressed_packet_number: Optional[int] = None,
) -> None:
"""Send `payload` to the MySQL server."""
return self._netbroker.send(
self.sock,
self.address,
payload,
packet_number=packet_number,
compressed_packet_number=compressed_packet_number,
)
def recv(self) -> bytearray:
"""Get packet from the MySQL server comm channel."""
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(
socket.AF_UNIX, socket.SOCK_STREAM # pylint: disable=no-member
)
self.sock.settimeout(self._connection_timeout)
self.sock.connect(self.unix_socket)
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: Union[
Tuple[None, None, None, None, None],
Tuple[
socket.AddressFamily,
socket.SocketKind,
int,
str,
Union[Tuple[str, int], Tuple[str, int, int, int]],
],
] = (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.address, _strioerror(err))
) from err
(self._family, socktype, proto, _, sockaddr) = addrinfo
# Instanciate the socket and connect
try:
self.sock = socket.socket(self._family, socktype, proto)
self.sock.settimeout(self._connection_timeout)
self.sock.connect(sockaddr)
except IOError as err:
raise InterfaceError(
errno=2003,
values=(
self.server_host,
self.server_port,
_strioerror(err),
),
) from err
except Exception as err:
raise OperationalError(str(err)) from err
@@ -0,0 +1,56 @@
"""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:
# falling back to the bundled installation
try:
from mysql.opentelemetry import trace
from mysql.opentelemetry.sdk.trace import TracerProvider
from mysql.opentelemetry.semconv.trace import SpanAttributes
except ImportError:
# bundled installation has missing dependencies
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,92 @@
"""Trace context propagation utilities."""
# mypy: disable-error-code="no-redef"
# pylint: disable=invalid-name
from typing import TYPE_CHECKING, Any, Callable, Union
from .constants import OTEL_ENABLED, TRACEPARENT_HEADER_NAME
if OTEL_ENABLED:
from .instrumentation import OTEL_SYSTEM_AVAILABLE
if OTEL_SYSTEM_AVAILABLE:
# pylint: disable=import-error
# load otel from the system
from opentelemetry import trace
from opentelemetry.trace.span import format_span_id, format_trace_id
else:
# load otel from the bundled installation
from mysql.opentelemetry import trace
from mysql.opentelemetry.trace.span import format_span_id, format_trace_id
if TYPE_CHECKING:
from ..connection import MySQLConnection
from ..connection_cext import CMySQLConnection
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
"""
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: Union["MySQLConnection", "CMySQLConnection"], *args: Any, **kwargs: Any
) -> Any:
"""Context propagation decorator."""
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,514 @@
"""MySQL instrumentation supporting mysql-connector."""
# mypy: disable-error-code="no-redef"
# pylint: disable=protected-access,global-statement,invalid-name
from __future__ import annotations
import functools
import re
from abc import ABC, abstractmethod
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
from ..connection import MySQLConnection
from ..cursor import MySQLCursor
from ..pooling import PooledMySQLConnection
try:
from ..connection_cext import CMySQLConnection
from ..cursor_cext import CMySQLCursor
except ImportError:
# The cext is not available.
pass
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
OTEL_SYSTEM_AVAILABLE = True
except ImportError:
try:
# falling back to the bundled installation
from mysql.opentelemetry import trace
from mysql.opentelemetry.semconv.trace import SpanAttributes
OTEL_SYSTEM_AVAILABLE = False
except ImportError as missing_dependencies_err:
raise connector.errors.ProgrammingError(
"Bundled installation has missing dependencies. "
"Please use `pip install mysql-connector-python[opentelemetry]`, "
"or for an editable install use `pip install -e '.[opentelemetry]'`, "
"to install the dependencies required by the bundled opentelemetry package."
) 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 instrument_execution(
query_method: Callable,
tracer: trace.Tracer,
connection_span_link: trace.Link,
wrapped: Union["MySQLCursor", "CMySQLCursor"],
*args: Any,
**kwargs: Any,
) -> Callable:
"""Instruments the execution of `query_method`.
A query span with a link to the corresponding connection span is generated.
"""
connection: Union["MySQLConnection", "CMySQLConnection"] = (
getattr(wrapped, "_connection")
if hasattr(wrapped, "_connection")
else getattr(wrapped, "_cnx")
)
# SpanAttributes.DB_NAME: connection.database or ""; introduces performance
# degradation, at this time the database attribute is something nice to have but
# not a requirement.
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": wrapped.__class__.__name__,
}
with tracer.start_as_current_span(
name=get_operation_name(args[0]) or "SQL statement",
kind=trace.SpanKind.CLIENT,
links=[connection_span_link],
attributes=query_span_attributes,
):
return query_method(*args, **kwargs)
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__:
# 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: Union["MySQLCursor", "CMySQLCursor"],
tracer: trace.Tracer,
connection_span: trace.Span,
):
"""Constructor."""
self._wrapped: Union["MySQLCursor", "CMySQLCursor"] = wrapped
self._tracer: trace.Tracer = tracer
self._connection_span_link: trace.Link = trace.Link(
connection_span.get_span_context()
)
def execute(self, *args: Any, **kwargs: Any) -> Any:
"""Instruments execute method."""
return instrument_execution(
self._wrapped.execute,
self._tracer,
self._connection_span_link,
self._wrapped,
*args,
**kwargs,
)
def executemany(self, *args: Any, **kwargs: Any) -> Any:
"""Instruments executemany method."""
return instrument_execution(
self._wrapped.executemany,
self._tracer,
self._connection_span_link,
self._wrapped,
*args,
**kwargs,
)
def callproc(self, *args: Any, **kwargs: Any) -> Any:
"""Instruments callproc method."""
return instrument_execution(
self._wrapped.callproc,
self._tracer,
self._connection_span_link,
self._wrapped,
*args,
**kwargs,
)
class TracedMySQLConnection(BaseMySQLTracer):
"""Wrapper class for a `MySQLConnection` or `CMySQLConnection` object."""
def __init__(self, wrapped: Union["MySQLConnection", "CMySQLConnection"]) -> None:
"""Constructor."""
self._wrapped: Union["MySQLConnection", "CMySQLConnection"] = 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 cursor object."""
return TracedMySQLCursor(
wrapped=self._wrapped.cursor(*args, **kwargs),
tracer=self._tracer,
connection_span=self._span,
)
def instrument_connect(
connect: Callable[
..., Union["MySQLConnection", "CMySQLConnection", "PooledMySQLConnection"]
],
tracer_provider: Optional[trace.TracerProvider] = None,
) -> Callable[
..., Union["MySQLConnection", "CMySQLConnection", "PooledMySQLConnection"]
]:
"""Retrurn the instrumented version of `connect`."""
# let's preserve `connect` identity.
@functools.wraps(connect)
def wrapper(
*args: Any, **kwargs: Any
) -> Union["MySQLConnection", "CMySQLConnection", "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
# Add basic net information.
set_connection_span_attrs(None, kwargs[OPTION_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: Union["MySQLConnection", "CMySQLConnection"],
tracer_provider: Optional[trace.TracerProvider] = None,
) -> Union["MySQLConnection", "CMySQLConnection"]:
"""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: Union["MySQLConnection", "CMySQLConnection"]
) -> Union["MySQLConnection", "CMySQLConnection"]:
"""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
+357
View File
@@ -0,0 +1,357 @@
# Copyright (c) 2014, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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 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 = option_parser.get_groups_as_dict_with_priority(*groups)
config_options: Dict[str, Tuple[str, int]] = {}
for group in groups:
try:
for option, value in config_from_file[group].items():
try:
if option == "socket":
option = "unix_socket"
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
not_evaluate = ("password", "passwd")
for option, value in config_options.items():
if option not in config:
try:
if option in not_evaluate:
config[option] = value[0]
else:
config[option] = eval(value[0]) # pylint: disable=eval-used
except (NameError, SyntaxError):
config[option] = value[0]
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
+102
View File
@@ -0,0 +1,102 @@
# Copyright (c) 2022, 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 also distributed 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 included with
# MySQL.
#
# 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."""
from abc import ABC
from typing import Optional, Union
from .. import errors
from ..types import StrOrBytes
class BaseAuthPlugin(ABC):
"""Base class for authentication pluginsF
Classes inheriting from BaseAuthPlugin should implement the method
prepare_password(). When instantiating, auth_data argument is
required. The username, password and database are optional. The
ssl_enabled argument can be used to tell the plugin whether SSL is
active or not.
The method auth_response() method is used to retrieve the password
which was prepared by prepare_password().
"""
requires_ssl: int = False
plugin_name: str = ""
def __init__(
self,
auth_data: Optional[bytes],
username: Optional[StrOrBytes] = None,
password: Optional[str] = None,
database: Optional[str] = None,
ssl_enabled: bool = False,
) -> None:
"""Initialization"""
self._auth_data: bytes = auth_data
self._username: Optional[str] = (
username.decode("utf8")
if isinstance(username, (bytes, bytearray))
else username
)
self._password: Optional[str] = password
self._database: Optional[str] = database
self._ssl_enabled: bool = ssl_enabled
def prepare_password(self) -> bytes:
"""Prepare and return password as as clear text.
Returns:
bytes: Prepared password.
"""
if not self._password:
return b"\x00"
password: StrOrBytes = self._password
if isinstance(password, str):
password = password.encode("utf8")
return password + b"\x00"
def auth_response(
self, auth_data: Optional[bytes] = None # pylint: disable=unused-argument
) -> 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.plugin_name} requires SSL")
return self.prepare_password()
@@ -0,0 +1,462 @@
# Copyright (c) 2022, 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 also distributed 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 included with
# MySQL.
#
# 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 pathlib import Path
from typing import Any, Optional, Tuple
from ..errors import InterfaceError, ProgrammingError
from ..logger import logger
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 BaseAuthPlugin
AUTHENTICATION_PLUGIN_CLASS = (
"MySQLSSPIKerberosAuthPlugin" if os.name == "nt" else "MySQLKerberosAuthPlugin"
)
# pylint: disable=c-extension-no-member,no-member
class MySQLKerberosAuthPlugin(BaseAuthPlugin):
"""Implement the MySQL Kerberos authentication plugin."""
plugin_name: str = "authentication_kerberos_client"
requires_ssl: bool = False
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) -> Optional[bytes]:
"""Prepare the first message to the server."""
spn = None
realm = None
if auth_data:
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.prepare_password()
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(BaseAuthPlugin):
"""Implement the MySQL Kerberos authentication plugin with Windows SSPI"""
plugin_name: str = "authentication_kerberos_client"
requires_ssl: bool = False
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) -> Optional[bytes]:
"""Prepare the first message to the server."""
logger.debug("auth_response for sspi")
spn = None
realm = None
if auth_data:
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
@@ -0,0 +1,496 @@
# Copyright (c) 2022, 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 also distributed 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 included with
# MySQL.
#
# 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 Any, Callable, List, Optional, Tuple
from uuid import uuid4
from ..errors import InterfaceError, ProgrammingError
from ..logger import logger
from ..types import StrOrBytes
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 BaseAuthPlugin
AUTHENTICATION_PLUGIN_CLASS = "MySQLLdapSaslPasswordAuthPlugin"
# pylint: disable=c-extension-no-member,no-member
class MySQLLdapSaslPasswordAuthPlugin(BaseAuthPlugin):
"""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"]
requires_ssl: bool = False
plugin_name: str = "authentication_ldap_sasl_client"
def_digest_mode: Callable = sha1
client_nonce: Optional[str] = None
client_salt: Any = None
server_salt: Optional[str] = None
krb_service_principal: Optional[StrOrBytes] = 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( # type: ignore[override]
self,
auth_data: Optional[str] = None,
) -> Optional[bytes]:
"""This method will prepare the fist message to the server.
Returns bytes to send to the server as the first message.
"""
krb_service_principal = auth_data
auth_mechanism = self._auth_data.decode()
self.krb_service_principal = krb_service_principal
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
# pylint: enable=c-extension-no-member,no-member
@@ -0,0 +1,190 @@
# Copyright (c) 2022, 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 also distributed 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 included with
# MySQL.
#
# 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 Any, Dict, Optional
from .. import errors
from ..logger import logger
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 BaseAuthPlugin
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(BaseAuthPlugin):
"""Implement the MySQL OCI IAM authentication plugin."""
plugin_name: str = "authentication_oci_client"
requires_ssl: bool = False
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
def auth_response(self, auth_data: Optional[bytes] = None) -> bytes:
"""Prepare authentication string for the server."""
logger.debug("server nonce: %s, len %d", self._auth_data, len(self._auth_data))
oci_config = self._get_valid_oci_config()
private_key = self._get_private_key(oci_config["key_file"])
signature = private_key.sign(
self._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()
@@ -0,0 +1,101 @@
# Copyright (c) 2022, 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 also distributed 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 included with
# MySQL.
#
# 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 Optional
from ..errors import InterfaceError
from . import BaseAuthPlugin
AUTHENTICATION_PLUGIN_CLASS = "MySQLCachingSHA2PasswordAuthPlugin"
class MySQLCachingSHA2PasswordAuthPlugin(BaseAuthPlugin):
"""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.
"""
requires_ssl: bool = False
plugin_name: str = "caching_sha2_password"
perform_full_authentication: int = 4
fast_auth_success: int = 3
def _scramble(self) -> 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 self._auth_data:
raise InterfaceError("Missing authentication data (seed)")
if not self._password:
return b""
password = (
self._password.encode("utf-8")
if isinstance(self._password, str)
else self._password
)
auth_data = self._auth_data
hash1 = sha256(password).digest()
hash2 = sha256()
hash2.update(sha256(hash1).digest())
hash2.update(auth_data)
hash2_digest: bytes = hash2.digest()
xored = [h1 ^ h2 for (h1, h2) in zip(hash1, hash2_digest)]
hash3 = struct.pack("32B", *xored)
return hash3
def _full_authentication(self) -> bytes:
"""Returns password as as clear text"""
if not self._ssl_enabled:
raise InterfaceError(f"{self.plugin_name} requires SSL")
return super().prepare_password()
def prepare_password(self) -> Optional[bytes]:
"""Prepare and return password.
Returns:
bytes: Prepared password.
"""
if not self._auth_data:
return None
if len(self._auth_data) > 1:
return self._scramble()
if self._auth_data[0] == self.perform_full_authentication:
return self._full_authentication()
return None
@@ -0,0 +1,40 @@
# Copyright (c) 2022, 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 also distributed 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 included with
# MySQL.
#
# 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 . import BaseAuthPlugin
AUTHENTICATION_PLUGIN_CLASS = "MySQLClearPasswordAuthPlugin"
class MySQLClearPasswordAuthPlugin(BaseAuthPlugin):
"""Class implementing the MySQL Clear Password authentication plugin"""
requires_ssl: bool = True
plugin_name: str = "mysql_clear_password"
@@ -0,0 +1,74 @@
# Copyright (c) 2022, 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 also distributed 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 included with
# MySQL.
#
# 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 ..errors import InterfaceError
from ..types import StrOrBytes
from . import BaseAuthPlugin
AUTHENTICATION_PLUGIN_CLASS = "MySQLNativePasswordAuthPlugin"
class MySQLNativePasswordAuthPlugin(BaseAuthPlugin):
"""Class implementing the MySQL Native Password authentication plugin"""
requires_ssl: bool = False
plugin_name: str = "mysql_native_password"
def prepare_password(self) -> bytes:
"""Prepares and returns password as native MySQL 4.1+ password"""
if not self._auth_data:
raise InterfaceError("Missing authentication data (seed)")
if not self._password:
return b""
password: StrOrBytes = self._password
if isinstance(self._password, str):
password = self._password.encode("utf-8")
else:
password = self._password
auth_data = self._auth_data
hash4 = None
try:
hash1 = sha1(password).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
@@ -0,0 +1,44 @@
# Copyright (c) 2022, 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 also distributed 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 included with
# MySQL.
#
# 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 . import BaseAuthPlugin
AUTHENTICATION_PLUGIN_CLASS = "MySQLSHA256PasswordAuthPlugin"
class MySQLSHA256PasswordAuthPlugin(BaseAuthPlugin):
"""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.
"""
requires_ssl: bool = True
plugin_name: str = "sha256_password"
+622
View File
@@ -0,0 +1,622 @@
# Copyright (c) 2013, 2022, Oracle and/or its affiliates. All rights reserved.
#
# 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 also distributed 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 included with
# MySQL.
#
# 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 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
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, MySQLConnection, CMySQLConnection]:
"""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, MySQLConnection, CMySQLConnection]:
"""Create or get a MySQL connection object.
In its simpliest form, connect() will open a connection to a
MySQL server and return a MySQLConnection object.
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.
Returns MySQLConnection or PooledMySQLConnection.
"""
# 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.
"""
def __init__(
self, pool: MySQLConnectionPool, cnx: Union[MySQLConnection, CMySQLConnection]
) -> None:
"""Initialize
The pool argument must be an instance of MySQLConnectionPoll. cnx
if an instance of MySQLConnection.
"""
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: Union[MySQLConnection, CMySQLConnection] = 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 add connection back to pool
The close() method does not close the connection with the
MySQL server. The connection is added back to the pool so it
can be reused.
When the pool is configured to reset the session, the session
state will be cleared by re-authenticating the user.
"""
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"""
raise PoolError(
"Configuration for pooled connections should be done through the "
"pool itself"
)
@property
def pool_name(self) -> str:
"""Return the name of the connection pool"""
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:
"""Initialize
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.
"""
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[
Union[MySQLConnection, CMySQLConnection]
] = 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:
"""Return the name of the connection pool"""
return self._pool_name
@property
def pool_size(self) -> int:
"""Return number of connections managed by the pool"""
return self._pool_size
@property
def reset_session(self) -> bool:
"""Return whether to reset session"""
return self._reset_session
def set_config(self, **kwargs: Any) -> None:
"""Set the connection configuration for MySQLConnection instances
This method sets the configuration used for creating MySQLConnection
instances. See MySQLConnection for valid connection arguments.
Raises PoolError when a connection argument is not valid, missing
or not supported by MySQLConnection.
"""
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: Union[MySQLConnection, CMySQLConnection]) -> 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 MySQLConnection")
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[Union[MySQLConnection, CMySQLConnection]] = None
) -> None:
"""Add 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.
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.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 MySQLConnection"
)
self._queue_connection(cnx)
def get_connection(self) -> PooledMySQLConnection:
"""Get 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.
Raises PoolError on errors.
Returns a PooledMySQLConnection instance.
"""
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
+994
View File
@@ -0,0 +1,994 @@
# Copyright (c) 2009, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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="assignment"
"""Implements the MySQL Client/Server protocol."""
import datetime
import struct
from decimal import Decimal, DecimalException
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
from . import utils
from .authentication import get_auth_plugin
from .constants import (
PARAMETER_COUNT_AVAILABLE,
ClientFlag,
FieldFlag,
FieldType,
ServerCmd,
)
from .errors import DatabaseError, InterfaceError, ProgrammingError, get_exception
from .types import (
ConnAttrsType,
DescriptionType,
EofPacketType,
HandShakeType,
OkPacketType,
ParseValueFromBinaryResultPacketTypes,
SocketType,
StatsPacketType,
StrOrBytes,
SupportedMysqlBinaryProtocolTypes,
)
PROTOCOL_VERSION: int = 10
class MySQLProtocol:
"""Implements MySQL client/server protocol
Create and parses MySQL packets.
"""
@staticmethod
def _connect_with_db(client_flags: int, database: Optional[str]) -> bytes:
"""Prepare database string for handshake response"""
if client_flags & ClientFlag.CONNECT_WITH_DB and database:
return database.encode("utf8") + b"\x00"
return b"\x00"
@staticmethod
def _auth_response(
client_flags: int,
username: Optional[StrOrBytes],
password: Optional[str],
database: Optional[str],
auth_plugin_class: str,
auth_plugin: str,
auth_data: Optional[bytes],
ssl_enabled: bool,
) -> bytes:
"""Prepare the authentication response"""
if not password:
return b"\x00"
try:
auth = get_auth_plugin(auth_plugin, auth_plugin_class)(
auth_data,
username=username,
password=password,
database=database,
ssl_enabled=ssl_enabled,
)
plugin_auth_response = auth.auth_response()
# # We could receive a NULL response, an Interface error is called when so
# if plugin_auth_response is None:
# raise InterfaceError
except (TypeError, InterfaceError) as err:
raise InterfaceError(f"Failed authentication: {err}") from err
if client_flags & ClientFlag.SECURE_CONNECTION:
resplen = len(plugin_auth_response)
auth_response = struct.pack("<B", resplen) + plugin_auth_response
else:
auth_response = plugin_auth_response + b"\x00"
return auth_response
def make_auth(
self,
handshake: Optional[HandShakeType],
username: Optional[StrOrBytes] = None,
password: Optional[str] = None,
database: Optional[str] = None,
charset: int = 45,
client_flags: int = 0,
max_allowed_packet: int = 1073741824,
ssl_enabled: bool = False,
auth_plugin: Optional[str] = None,
conn_attrs: Optional[ConnAttrsType] = None,
auth_plugin_class: Optional[str] = None,
) -> bytes:
"""Make a MySQL Authentication packet"""
if handshake is None:
handshake = {}
try:
auth_data: Optional[bytes] = handshake["auth_data"]
auth_plugin = auth_plugin or handshake["auth_plugin"]
except (TypeError, KeyError) as err:
raise ProgrammingError(
f"Handshake misses authentication info ({err})"
) from None
if not username:
username = b""
try:
username_bytes = username.encode("utf8") # type: ignore[union-attr]
except AttributeError:
# Username is already bytes
username_bytes = username
filler = "x" * 22
username_len = len(username_bytes)
packet = struct.pack(
f"<IIH{filler}{username_len}sx",
client_flags,
max_allowed_packet,
charset,
username_bytes,
)
packet += self._auth_response(
client_flags,
username,
password,
database,
auth_plugin_class,
auth_plugin,
auth_data,
ssl_enabled,
)
packet += self._connect_with_db(client_flags, database)
if client_flags & ClientFlag.PLUGIN_AUTH:
packet += auth_plugin.encode("utf8") + b"\x00"
if (client_flags & ClientFlag.CONNECT_ARGS) and conn_attrs is not None:
packet += self.make_conn_attrs(conn_attrs)
return packet
@staticmethod
def make_conn_attrs(conn_attrs: ConnAttrsType) -> bytes:
"""Encode the connection attributes"""
for attr_name in conn_attrs:
if conn_attrs[attr_name] is None:
conn_attrs[attr_name] = ""
conn_attrs_len = (
sum(len(x) + len(conn_attrs[x]) for x in conn_attrs)
+ len(conn_attrs.keys())
+ len(conn_attrs.values())
)
conn_attrs_packet = struct.pack("<B", conn_attrs_len)
for attr_name in conn_attrs:
conn_attrs_packet += struct.pack("<B", len(attr_name))
conn_attrs_packet += attr_name.encode("utf8")
conn_attrs_packet += struct.pack("<B", len(conn_attrs[attr_name]))
conn_attrs_packet += conn_attrs[attr_name].encode("utf8") # type: ignore[union-attr]
return conn_attrs_packet
@staticmethod
def make_auth_ssl(
charset: int = 45, client_flags: int = 0, max_allowed_packet: int = 1073741824
) -> bytearray:
"""Make a SSL authentication packet"""
return (
utils.int4store(client_flags)
+ utils.int4store(max_allowed_packet)
+ utils.int2store(charset)
+ b"\x00" * 22
)
@staticmethod
def make_command(command: int, argument: Optional[bytes] = None) -> bytearray:
"""Make a MySQL packet containing a command"""
data = utils.int1store(command)
if argument is not None:
data += argument
return data
@staticmethod
def make_stmt_fetch(statement_id: int, rows: int = 1) -> bytearray:
"""Make a MySQL packet with Fetch Statement command"""
return utils.int4store(statement_id) + utils.int4store(rows)
def make_change_user(
self,
handshake: HandShakeType,
username: Optional[StrOrBytes] = None,
password: Optional[str] = None,
database: Optional[str] = None,
charset: int = 45,
client_flags: int = 0,
ssl_enabled: bool = False,
auth_plugin: Optional[str] = None,
conn_attrs: Optional[ConnAttrsType] = None,
auth_plugin_class: Optional[str] = None,
) -> bytes:
"""Make a MySQL packet with the Change User command"""
try:
auth_data: Optional[bytes] = handshake["auth_data"]
auth_plugin = auth_plugin or handshake["auth_plugin"]
except (TypeError, KeyError) as err:
raise ProgrammingError(
f"Handshake misses authentication info ({err})"
) from None
if not username:
username = b""
try:
username_bytes = username.encode("utf8") # type: ignore[union-attr]
except AttributeError:
# Username is already bytes
username_bytes = username
username_len = len(username_bytes)
packet = struct.pack(
f"<B{username_len}sx",
ServerCmd.CHANGE_USER,
username_bytes,
)
packet += self._auth_response(
client_flags,
username,
password,
database,
auth_plugin_class,
auth_plugin,
auth_data,
ssl_enabled,
)
packet += self._connect_with_db(client_flags, database)
packet += struct.pack("<H", charset)
if client_flags & ClientFlag.PLUGIN_AUTH:
packet += auth_plugin.encode("utf8") + b"\x00"
if (client_flags & ClientFlag.CONNECT_ARGS) and conn_attrs is not None:
packet += self.make_conn_attrs(conn_attrs)
return packet
@staticmethod
def parse_handshake(packet: bytes) -> HandShakeType:
"""Parse a MySQL Handshake-packet"""
res = {}
res["protocol"] = struct.unpack("<xxxxB", packet[0:5])[0]
if res["protocol"] != PROTOCOL_VERSION:
raise DatabaseError(
f"Protocol mismatch; server version = {res['protocol']}, "
f"client version = {PROTOCOL_VERSION}"
)
packet, res["server_version_original"] = utils.read_string(
packet[5:], end=b"\x00"
)
(
res["server_threadid"],
auth_data1,
capabilities1,
res["charset"],
res["server_status"],
capabilities2,
auth_data_length,
) = struct.unpack("<I8sx2sBH2sBxxxxxxxxxx", packet[0:31])
res["server_version_original"] = res["server_version_original"].decode()
packet = packet[31:]
capabilities = utils.intread(capabilities1 + capabilities2)
auth_data2 = b""
if capabilities & ClientFlag.SECURE_CONNECTION:
size = min(13, auth_data_length - 8) if auth_data_length else 13
auth_data2 = packet[0:size]
packet = packet[size:]
if auth_data2[-1] == 0:
auth_data2 = auth_data2[:-1]
if capabilities & ClientFlag.PLUGIN_AUTH:
if b"\x00" not in packet and res["server_version_original"].startswith(
"5.5.8"
):
# MySQL server 5.5.8 has a bug where end byte is not send
(packet, res["auth_plugin"]) = (b"", packet)
else:
(packet, res["auth_plugin"]) = utils.read_string(packet, end=b"\x00")
res["auth_plugin"] = res["auth_plugin"].decode("utf-8")
else:
res["auth_plugin"] = "mysql_native_password"
res["auth_data"] = auth_data1 + auth_data2
res["capabilities"] = capabilities
return res
@staticmethod
def parse_auth_next_factor(packet: bytes) -> Tuple[bytes, str]:
"""Parse a MySQL AuthNextFactor packet."""
packet, status = utils.read_int(packet, 1)
if status != 2:
raise InterfaceError("Failed parsing AuthNextFactor packet (invalid)")
packet, auth_plugin = utils.read_string(packet, end=b"\x00")
return packet, auth_plugin.decode("utf-8")
@staticmethod
def parse_ok(packet: bytes) -> OkPacketType:
"""Parse a MySQL OK-packet"""
if not packet[4] == 0:
raise InterfaceError("Failed parsing OK packet (invalid).")
ok_packet = {}
try:
ok_packet["field_count"] = struct.unpack("<xxxxB", packet[0:5])[0]
packet, ok_packet["affected_rows"] = utils.read_lc_int(packet[5:])
packet, ok_packet["insert_id"] = utils.read_lc_int(packet)
(
ok_packet["status_flag"],
ok_packet["warning_count"],
) = struct.unpack("<HH", packet[0:4])
packet = packet[4:]
if packet:
packet, ok_packet["info_msg"] = utils.read_lc_string(packet)
ok_packet["info_msg"] = ok_packet["info_msg"].decode("utf-8")
except ValueError as err:
raise InterfaceError("Failed parsing OK packet.") from err
return ok_packet
@staticmethod
def parse_column_count(packet: bytes) -> Optional[int]:
"""Parse a MySQL packet with the number of columns in result set"""
try:
count = utils.read_lc_int(packet[4:])[1]
return count
except (struct.error, ValueError) as err:
raise InterfaceError("Failed parsing column count") from err
@staticmethod
def parse_column(packet: bytes, encoding: str = "utf-8") -> DescriptionType:
"""Parse a MySQL column-packet"""
packet, _ = utils.read_lc_string(packet[4:]) # catalog
packet, _ = utils.read_lc_string(packet) # db
packet, _ = utils.read_lc_string(packet) # table
packet, _ = utils.read_lc_string(packet) # org_table
packet, name = utils.read_lc_string(packet) # name
packet, _ = utils.read_lc_string(packet) # org_name
try:
(
charset,
_,
column_type,
flags,
_,
) = struct.unpack("<xHIBHBxx", packet)
except struct.error:
raise InterfaceError("Failed parsing column information") from None
return (
name.decode(encoding),
column_type,
None, # display_size
None, # internal_size
None, # precision
None, # scale
~flags & FieldFlag.NOT_NULL, # null_ok
flags, # MySQL specific
charset,
)
def parse_eof(self, packet: bytes) -> EofPacketType:
"""Parse a MySQL EOF-packet"""
if packet[4] == 0:
# EOF packet deprecation
return self.parse_ok(packet)
err_msg = "Failed parsing EOF packet."
res = {}
try:
unpacked = struct.unpack("<xxxBBHH", packet)
except struct.error as err:
raise InterfaceError(err_msg) from err
if not (unpacked[1] == 254 and len(packet) <= 9):
raise InterfaceError(err_msg)
res["warning_count"] = unpacked[2]
res["status_flag"] = unpacked[3]
return res
@staticmethod
def parse_statistics(packet: bytes, with_header: bool = True) -> StatsPacketType:
"""Parse the statistics packet"""
errmsg = "Failed getting COM_STATISTICS information"
res: Dict[str, Union[int, Decimal]] = {}
# Information is separated by 2 spaces
pairs: List[bytes] = [b""]
lbl: StrOrBytes = b""
if with_header:
pairs = packet[4:].split(b"\x20\x20")
else:
pairs = packet.split(b"\x20\x20")
for pair in pairs:
try:
lbl, val = [v.strip() for v in pair.split(b":", 2)]
except ValueError as err:
raise InterfaceError(errmsg) from err
# It's either an integer or a decimal
lbl = lbl.decode("utf-8")
try:
res[lbl] = int(val)
except (KeyError, ValueError):
try:
res[lbl] = Decimal(val.decode("utf-8"))
except DecimalException as err:
raise InterfaceError(f"{errmsg} ({lbl}:{repr(val)})") from err
return res
def read_text_result(
self, sock: SocketType, version: Tuple[int, ...], count: int = 1
) -> 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 = sock.recv()
if packet.startswith(b"\xff\xff\xff"):
datas = [packet[4:]]
packet = sock.recv()
while packet.startswith(b"\xff\xff\xff"):
datas.append(packet[4:])
packet = sock.recv()
datas.append(packet[4:])
rowdata = utils.read_lc_string_list(bytearray(b"").join(datas))
elif packet[4] == 254 and packet[0] < 7:
eof = self.parse_eof(packet)
rowdata = None
else:
eof = None
rowdata = utils.read_lc_string_list(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
@staticmethod
def _parse_binary_integer(
packet: bytes, field: DescriptionType
) -> Tuple[bytes, int]:
"""Parse an integer from a binary packet"""
if field[1] == FieldType.TINY:
format_ = "<b"
length = 1
elif field[1] == FieldType.SHORT:
format_ = "<h"
length = 2
elif field[1] in (FieldType.INT24, FieldType.LONG):
format_ = "<i"
length = 4
elif field[1] == FieldType.LONGLONG:
format_ = "<q"
length = 8
if field[7] & FieldFlag.UNSIGNED:
format_ = format_.upper()
return (packet[length:], struct.unpack(format_, packet[0:length])[0])
@staticmethod
def _parse_binary_float(
packet: bytes, field: DescriptionType
) -> Tuple[bytes, float]:
"""Parse a float/double from a binary packet"""
if field[1] == FieldType.DOUBLE:
length = 8
format_ = "<d"
else:
length = 4
format_ = "<f"
return (packet[length:], struct.unpack(format_, packet[0:length])[0])
@staticmethod
def _parse_binary_new_decimal(
packet: bytes, charset: str = "utf8"
) -> Tuple[bytes, Decimal]:
"""Parse a New Decimal from a binary packet"""
(packet, value) = utils.read_lc_string(packet)
return (packet, Decimal(value.decode(charset)))
@staticmethod
def _parse_binary_timestamp(
packet: bytes,
field_type: int,
) -> Tuple[bytes, Optional[Union[datetime.date, datetime.datetime]]]:
"""Parse a timestamp from a binary packet"""
length = packet[0]
value = None
if length == 4:
year = struct.unpack("<H", packet[1:3])[0]
month = packet[3]
day = packet[4]
if field_type in (FieldType.DATETIME, FieldType.TIMESTAMP):
value = datetime.datetime(year=year, month=month, day=day)
else:
value = datetime.date(year=year, month=month, day=day)
elif length >= 7:
mcs = 0
if length == 11:
mcs = struct.unpack("<I", packet[8 : length + 1])[0]
value = datetime.datetime(
year=struct.unpack("<H", packet[1:3])[0],
month=packet[3],
day=packet[4],
hour=packet[5],
minute=packet[6],
second=packet[7],
microsecond=mcs,
)
return (packet[length + 1 :], value)
@staticmethod
def _parse_binary_time(packet: bytes) -> Tuple[bytes, datetime.timedelta]:
"""Parse a time value from a binary packet"""
length = packet[0]
if not length:
return (packet[1:], datetime.timedelta())
data = packet[1 : length + 1]
mcs = 0
if length > 8:
mcs = struct.unpack("<I", data[8:])[0]
days = struct.unpack("<I", data[1:5])[0]
if data[0] == 1:
days *= -1
tmp = datetime.timedelta(
days=days,
seconds=data[7],
microseconds=mcs,
minutes=data[6],
hours=data[5],
)
return (packet[length + 1 :], tmp)
def _parse_binary_values(
self,
fields: List[DescriptionType],
packet: bytes,
charset: str = "utf-8",
) -> Tuple[ParseValueFromBinaryResultPacketTypes, ...]:
"""Parse values from a binary result packet"""
null_bitmap_length = (len(fields) + 7 + 2) // 8
null_bitmap = [int(i) for i in packet[0:null_bitmap_length]]
packet = packet[null_bitmap_length:]
values: List[Any] = []
value: Any = None
for pos, field in enumerate(fields):
if null_bitmap[int((pos + 2) / 8)] & (1 << (pos + 2) % 8):
values.append(None)
continue
if field[1] in (
FieldType.TINY,
FieldType.SHORT,
FieldType.INT24,
FieldType.LONG,
FieldType.LONGLONG,
):
packet, value = self._parse_binary_integer(packet, field)
values.append(value)
elif field[1] in (FieldType.DOUBLE, FieldType.FLOAT):
packet, value = self._parse_binary_float(packet, field)
values.append(value)
elif field[1] in (FieldType.DECIMAL, FieldType.NEWDECIMAL):
packet, value = self._parse_binary_new_decimal(packet, charset)
values.append(value)
elif field[1] in (
FieldType.DATETIME,
FieldType.DATE,
FieldType.TIMESTAMP,
):
(packet, value) = self._parse_binary_timestamp(packet, field[1])
values.append(value)
elif field[1] == FieldType.TIME:
(packet, value) = self._parse_binary_time(packet)
values.append(value)
else:
(packet, value) = utils.read_lc_string(packet)
try:
values.append(value.decode(charset))
except UnicodeDecodeError:
values.append(value)
return tuple(values)
def read_binary_result(
self,
sock: SocketType,
columns: List[DescriptionType],
count: int = 1,
charset: str = "utf-8",
) -> Tuple[
List[Tuple[ParseValueFromBinaryResultPacketTypes, ...]],
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 is not None:
break
if i == count:
break
packet = sock.recv()
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)
@staticmethod
def parse_binary_prepare_ok(packet: bytes) -> Dict[str, int]:
"""Parse a MySQL Binary Protocol OK packet"""
if not packet[4] == 0:
raise InterfaceError("Failed parsing Binary OK packet")
ok_pkt = {}
try:
packet, ok_pkt["statement_id"] = utils.read_int(packet[5:], 4)
packet, ok_pkt["num_columns"] = utils.read_int(packet, 2)
packet, ok_pkt["num_params"] = utils.read_int(packet, 2)
packet = packet[1:] # Filler 1 * \x00
packet, ok_pkt["warning_count"] = utils.read_int(packet, 2)
except ValueError as err:
raise InterfaceError("Failed parsing Binary OK packet") from err
return ok_pkt
@staticmethod
def prepare_binary_integer(value: int) -> Tuple[bytes, int, int]:
"""Prepare an integer for the MySQL binary protocol"""
field_type = None
flags = 0
if value < 0:
if value >= -128:
format_ = "<b"
field_type = FieldType.TINY
elif value >= -32768:
format_ = "<h"
field_type = FieldType.SHORT
elif value >= -2147483648:
format_ = "<i"
field_type = FieldType.LONG
else:
format_ = "<q"
field_type = FieldType.LONGLONG
else:
flags = 128
if value <= 255:
format_ = "<B"
field_type = FieldType.TINY
elif value <= 65535:
format_ = "<H"
field_type = FieldType.SHORT
elif value <= 4294967295:
format_ = "<I"
field_type = FieldType.LONG
else:
field_type = FieldType.LONGLONG
format_ = "<Q"
return (struct.pack(format_, value), field_type, flags)
@staticmethod
def prepare_binary_timestamp(
value: Union[datetime.date, datetime.datetime]
) -> Tuple[bytearray, int]:
"""Prepare a timestamp object for the MySQL binary protocol
This method prepares a timestamp of type datetime.datetime or
datetime.date for sending over the MySQL binary protocol.
A tuple is returned with the prepared value and field type
as elements.
Raises ValueError when the argument value is of invalid type.
Returns a tuple.
"""
if isinstance(value, datetime.datetime):
field_type = FieldType.DATETIME
elif isinstance(value, datetime.date):
field_type = FieldType.DATE
else:
raise ValueError("Argument must a datetime.datetime or datetime.date")
packed = (
utils.int2store(value.year)
+ utils.int1store(value.month)
+ utils.int1store(value.day)
)
if isinstance(value, datetime.datetime):
packed = (
packed
+ utils.int1store(value.hour)
+ utils.int1store(value.minute)
+ utils.int1store(value.second)
)
if value.microsecond > 0:
packed += utils.int4store(value.microsecond)
packed = utils.int1store(len(packed)) + packed
return (packed, field_type)
@staticmethod
def prepare_binary_time(
value: Union[datetime.timedelta, datetime.time]
) -> Tuple[bytearray, int]:
"""Prepare a time object for the MySQL binary protocol
This method prepares a time object of type datetime.timedelta or
datetime.time for sending over the MySQL binary protocol.
A tuple is returned with the prepared value and field type
as elements.
Raises ValueError when the argument value is of invalid type.
Returns a tuple.
"""
if not isinstance(value, (datetime.timedelta, datetime.time)):
raise ValueError("Argument must a datetime.timedelta or datetime.time")
field_type = FieldType.TIME
negative = 0
mcs = None
packed = b""
if isinstance(value, datetime.timedelta):
if value.days < 0:
negative = 1
(hours, remainder) = divmod(value.seconds, 3600)
(mins, secs) = divmod(remainder, 60)
packed += (
utils.int4store(abs(value.days))
+ utils.int1store(hours)
+ utils.int1store(mins)
+ utils.int1store(secs)
)
mcs = value.microseconds
else:
packed += (
utils.int4store(0)
+ utils.int1store(value.hour)
+ utils.int1store(value.minute)
+ utils.int1store(value.second)
)
mcs = value.microsecond
if mcs:
packed += utils.int4store(mcs)
packed = utils.int1store(negative) + packed
packed = utils.int1store(len(packed)) + packed
return (packed, field_type)
@staticmethod
def prepare_stmt_send_long_data(
statement: int, param: int, data: bytes
) -> bytearray:
"""Prepare long data for prepared statements
Returns a string.
"""
packet: bytearray = utils.int4store(statement) + utils.int2store(param) + data
return packet
def make_stmt_execute(
self,
statement_id: int,
data: Sequence[SupportedMysqlBinaryProtocolTypes] = (),
parameters: Sequence[Any] = (),
flags: int = 0,
long_data_used: Optional[Dict[int, Tuple[bool]]] = None,
charset: str = "utf8",
query_attrs: Optional[List[Tuple[str, Any]]] = None,
converter_str_fallback: bool = False,
) -> bytearray:
"""Make a MySQL packet with the Statement Execute command"""
iteration_count = 1
null_bitmap = [0] * ((len(data) + 7) // 8)
values = []
types = []
packed = b""
data_len = len(data)
query_attr_names = []
flags = flags if not query_attrs else flags + PARAMETER_COUNT_AVAILABLE
if charset == "utf8mb4":
charset = "utf8"
if long_data_used is None:
long_data_used = {}
if query_attrs:
data = list(data)
for _, attr_val in query_attrs:
data.append(attr_val)
null_bitmap = [0] * ((len(data) + 7) // 8)
if parameters or data:
if data_len != len(parameters):
raise InterfaceError(
"Failed executing prepared statement: data values does not"
" match number of parameters"
)
for pos, value in enumerate(data):
_flags = 0
if value is None:
null_bitmap[(pos // 8)] |= 1 << (pos % 8)
types.append(
utils.int1store(FieldType.NULL) + utils.int1store(_flags)
)
continue
if pos in long_data_used:
if long_data_used[pos][0]:
# We suppose binary data
field_type = FieldType.BLOB
else:
# We suppose text data
field_type = FieldType.STRING
elif isinstance(value, int):
(
packed,
field_type,
_flags,
) = self.prepare_binary_integer(value)
values.append(packed)
elif isinstance(value, str):
value = value.encode(charset)
values.append(utils.lc_int(len(value)) + value)
field_type = FieldType.STRING
elif isinstance(value, bytes):
values.append(utils.lc_int(len(value)) + value)
field_type = FieldType.STRING
elif isinstance(value, Decimal):
values.append(
utils.lc_int(len(str(value).encode(charset)))
+ str(value).encode(charset)
)
field_type = FieldType.DECIMAL
elif isinstance(value, float):
values.append(struct.pack("<d", value))
field_type = FieldType.DOUBLE
elif isinstance(value, (datetime.datetime, datetime.date)):
(packed, field_type) = self.prepare_binary_timestamp(value)
values.append(packed)
elif isinstance(value, (datetime.timedelta, datetime.time)):
(packed, field_type) = self.prepare_binary_time(value)
values.append(packed)
elif converter_str_fallback:
value = str(value).encode(charset)
values.append(utils.lc_int(len(value)) + value)
field_type = FieldType.STRING
else:
raise ProgrammingError(
"MySQL binary protocol can not handle "
f"'{value.__class__.__name__}' objects"
)
types.append(utils.int1store(field_type) + utils.int1store(_flags))
if query_attrs and pos + 1 > data_len:
name = query_attrs[pos - data_len][0].encode(charset)
query_attr_names.append(utils.lc_int(len(name)) + name)
packet = (
utils.int4store(statement_id)
+ utils.int1store(flags)
+ utils.int4store(iteration_count)
)
# if (num_params > 0 || (CLIENT_QUERY_ATTRIBUTES \
# && (flags & PARAMETER_COUNT_AVAILABLE)) {
if query_attrs is not None:
parameter_count = data_len + len(query_attrs)
else:
parameter_count = data_len
if parameter_count:
# if CLIENT_QUERY_ATTRIBUTES is on
if query_attrs is not None:
packet += utils.lc_int(parameter_count)
packet += b"".join(
[struct.pack("B", bit) for bit in null_bitmap]
) + utils.int1store(1)
count = 0
for a_type in types:
packet += a_type
# if CLIENT_QUERY_ATTRIBUTES is on {
# string<lenenc> parameter_name Name of the parameter
# or empty if not present
# } if CLIENT_QUERY_ATTRIBUTES is on
if query_attrs is not None:
if count + 1 > data_len:
packet += query_attr_names[count - data_len]
else:
packet += b"\x00"
count += 1
for a_value in values:
packet += a_value
return packet
@staticmethod
def parse_auth_switch_request(packet: bytes) -> Tuple[str, bytes]:
"""Parse a MySQL AuthSwitchRequest-packet"""
if not packet[4] == 254:
raise InterfaceError("Failed parsing AuthSwitchRequest packet")
packet, plugin_name = utils.read_string(packet[5:], end=b"\x00")
if packet and packet[-1] == 0:
packet = packet[:-1]
return plugin_name.decode("utf8"), packet
@staticmethod
def parse_auth_more_data(packet: bytes) -> bytes:
"""Parse a MySQL AuthMoreData-packet"""
if not packet[4] == 1:
raise InterfaceError("Failed parsing AuthMoreData packet")
return packet[5:]
View File
+130
View File
@@ -0,0 +1,130 @@
"""
Type hint aliases hub
"""
import os
import typing
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from time import struct_time
from typing import (
TYPE_CHECKING,
Dict,
List,
Mapping,
Optional,
Sequence,
Set,
Tuple,
Union,
)
if hasattr(typing, "TypeAlias"):
# pylint: disable=no-name-in-module
from typing import TypeAlias # type: ignore[attr-defined]
else:
try:
from typing_extensions import TypeAlias
except ModuleNotFoundError:
# pylint: disable=reimported
from typing import Any as TypeAlias
if TYPE_CHECKING:
from .custom_types import HexLiteral
from .network import MySQLSocket
StrOrBytes = Union[str, bytes]
StrOrBytesPath = Union[StrOrBytes, os.PathLike]
SocketType: TypeAlias = "MySQLSocket"
""" Conversion """
ToPythonOutputTypes = Optional[
Union[
float,
int,
Decimal,
StrOrBytes,
date,
timedelta,
datetime,
Set[str],
]
]
ToMysqlInputTypes = Optional[
Union[
int,
float,
Decimal,
StrOrBytes,
bool,
datetime,
date,
time,
struct_time,
timedelta,
]
]
ToMysqlOutputTypes = Optional[Union[int, float, bytes, "HexLiteral"]]
""" Protocol """
HandShakeType = Dict[str, Optional[Union[int, StrOrBytes]]]
OkPacketType = Dict[str, Optional[Union[int, str]]]
EofPacketType = OkPacketType
StatsPacketType = Dict[str, Union[int, Decimal]]
SupportedMysqlBinaryProtocolTypes = Optional[
Union[int, StrOrBytes, Decimal, float, datetime, date, timedelta, time]
]
QueryAttrType = List[
# 2-Tuple: (str, attr_types)
Tuple[str, SupportedMysqlBinaryProtocolTypes]
]
ParseValueFromBinaryResultPacketTypes = Optional[
Union[
int,
float,
Decimal,
date,
datetime,
timedelta,
str,
]
]
DescriptionType = Tuple[
# Sometimes it can be represented as a 2-Tuple of the form:
# Tuple[str, int], # field name, field type,
# but we will stick with the 9-Tuple format produced by
# the protocol module.
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
]
""" Connection """
ConnAttrsType = Dict[str, Optional[Union[str, Tuple[str, str]]]]
ResultType = Mapping[
str, Optional[Union[int, str, EofPacketType, List[DescriptionType]]]
]
""" Connection C-EXT """
CextEofPacketType = Dict[str, int]
CextResultType = Dict[str, Union[CextEofPacketType, List[DescriptionType]]]
""" Cursor """
ParamsSequenceType = Sequence[ToMysqlInputTypes]
ParamsDictType = Dict[str, ToMysqlInputTypes]
ParamsSequenceOrDictType = Union[ParamsDictType, ParamsSequenceType]
RowType = Tuple[ToPythonOutputTypes, ...]
WarningType = Tuple[str, int, str]
+636
View File
@@ -0,0 +1,636 @@
# Copyright (c) 2009, 2022, 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 also distributed 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 included with
# MySQL.
#
# 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 os
import platform
import struct
import subprocess
import sys
import unicodedata
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 Callable, Dict, List, Optional, Tuple, Type, Union
from .custom_types import HexLiteral
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 = len(buf)
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) -> bytearray:
"""
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 bytearray(struct.pack("<B", i))
def int2store(i: int) -> bytearray:
"""
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 bytearray(struct.pack("<H", i))
def int3store(i: int) -> bytearray:
"""
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 bytearray(struct.pack("<I", i)[0:3])
def int4store(i: int) -> bytearray:
"""
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 bytearray(struct.pack("<I", i))
def int8store(i: int) -> bytearray:
"""
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 bytearray(struct.pack("<Q", i))
def intstore(i: int) -> bytearray:
"""
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 bytearray(struct.pack("<B", i))
if i <= 65535:
return b"\xfc" + bytearray(struct.pack("<H", i))
if i <= 16777215:
return b"\xfd" + bytearray(struct.pack("<I", i)[0:3])
return b"\xfe" + bytearray(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
+46
View File
@@ -0,0 +1,46 @@
# Copyright (c) 2012, 2023, Oracle and/or its affiliates. All rights reserved.
#
# 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 also distributed 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 included with
# MySQL.
#
# 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 = (8, 1, 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