diff --git a/.coveragerc b/.coveragerc
index a9ec99425..efa9a2ff8 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -2,6 +2,7 @@
branch = True
source =
pymysql
+ tests
omit = pymysql/tests/*
pymysql/tests/thirdparty/test_MySQLdb/*
diff --git a/.flake8 b/.flake8
deleted file mode 100644
index 3f1c38a3f..000000000
--- a/.flake8
+++ /dev/null
@@ -1,4 +0,0 @@
-[flake8]
-exclude = tests,build,.venv,docs
-ignore = E203,W503,E722
-max_line_length=129
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 89fc5cf8f..253a13aca 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,10 +1,10 @@
# These are supported funding model platforms
-github: [methane] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
+github: ["methane"] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
-tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
+tidelift: "pypi/PyMySQL" # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
deleted file mode 100644
index d559b1cd2..000000000
--- a/.github/workflows/codeql-analysis.yml
+++ /dev/null
@@ -1,67 +0,0 @@
-# For most projects, this workflow file will not need changing; you simply need
-# to commit it to your repository.
-#
-# You may wish to alter this file to override the set of languages analyzed,
-# or to provide custom queries or build logic.
-#
-# ******** NOTE ********
-# We have attempted to detect the languages in your repository. Please check
-# the `language` matrix defined below to confirm you have the correct set of
-# supported CodeQL languages.
-#
-name: "CodeQL"
-
-on:
- push:
- branches: [ main ]
- pull_request:
- # The branches below must be a subset of the branches above
- branches: [ main ]
- schedule:
- - cron: '34 7 * * 2'
-
-jobs:
- analyze:
- name: Analyze
- runs-on: ubuntu-latest
-
- strategy:
- fail-fast: false
- matrix:
- language: [ 'python' ]
- # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
- # Learn more:
- # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v2
-
- # Initializes the CodeQL tools for scanning.
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v2
- with:
- languages: ${{ matrix.language }}
- # If you wish to specify custom queries, you can do so here or in a config file.
- # By default, queries listed here will override any specified in a config file.
- # Prefix the list here with "+" to use these queries and those in the config file.
- # queries: ./path/to/local/query, your-org/your-repo/queries@main
-
- # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
- # If this step fails, then you should remove it and run the build manually (see below)
- - name: Autobuild
- uses: github/codeql-action/autobuild@v2
-
- # âšī¸ Command-line programs to run using the OS shell.
- # đ https://git.io/JvXDl
-
- # âī¸ If the Autobuild fails above, remove it and uncomment the following three lines
- # and modify them (or add more) to build your code if your project
- # uses a compiled language
-
- #- run: |
- # make bootstrap
- # make release
-
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
diff --git a/.github/workflows/django.yaml b/.github/workflows/django.yaml
new file mode 100644
index 000000000..5c4609543
--- /dev/null
+++ b/.github/workflows/django.yaml
@@ -0,0 +1,66 @@
+name: Django test
+
+on:
+ push:
+ # branches: ["main"]
+ # pull_request:
+
+jobs:
+ django-test:
+ name: "Run Django LTS test suite"
+ runs-on: ubuntu-latest
+ # There are some known difference between MySQLdb and PyMySQL.
+ continue-on-error: true
+ env:
+ PIP_NO_PYTHON_VERSION_WARNING: 1
+ PIP_DISABLE_PIP_VERSION_CHECK: 1
+ # DJANGO_VERSION: "3.2.19"
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Django 3.2.9+ supports Python 3.10
+ # https://docs.djangoproject.com/ja/3.2/releases/3.2/
+ - django: "3.2.19"
+ python: "3.10"
+
+ - django: "4.2.1"
+ python: "3.11"
+
+ steps:
+ - name: Start MySQL
+ run: |
+ sudo systemctl start mysql.service
+ mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -uroot -proot mysql
+ mysql -uroot -proot -e "set global innodb_flush_log_at_trx_commit=0;"
+ mysql -uroot -proot -e "CREATE USER 'scott'@'%' IDENTIFIED BY 'tiger'; GRANT ALL ON *.* TO scott;"
+ mysql -uroot -proot -e "CREATE DATABASE django_default; CREATE DATABASE django_other;"
+
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python }}
+
+ - name: Install mysqlclient
+ run: |
+ #pip install mysqlclient # Use stable version
+ pip install .[rsa]
+
+ - name: Setup Django
+ run: |
+ sudo apt-get install libmemcached-dev
+ wget https://github.com/django/django/archive/${{ matrix.django }}.tar.gz
+ tar xf ${{ matrix.django }}.tar.gz
+ cp ci/test_mysql.py django-${{ matrix.django }}/tests/
+ cd django-${{ matrix.django }}
+ pip install . -r tests/requirements/py3.txt
+
+ - name: Run Django test
+ run: |
+ cd django-${{ matrix.django }}/tests/
+ # test_runner does not using our test_mysql.py
+ # We can't run whole django test suite for now.
+ # Run olly backends test
+ DJANGO_SETTINGS_MODULE=test_mysql python runtests.py backends
diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
index a3131ce25..07ea66031 100644
--- a/.github/workflows/lint.yaml
+++ b/.github/workflows/lint.yaml
@@ -2,6 +2,7 @@ name: Lint
on:
push:
+ branches: ["main"]
paths:
- '**.py'
pull_request:
@@ -12,16 +13,12 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
- - uses: actions/setup-python@v4
- with:
- python-version: 3.x
- - uses: psf/black@stable
- with:
- args: ". --diff --check"
- - name: Setup flake8 annotations
- uses: rbialon/flake8-annotations@v1
- - name: flake8
- run: |
- pip install flake8
- flake8 pymysql
+ - uses: actions/checkout@v4
+
+ - uses: astral-sh/ruff-action@v3
+
+ - name: format
+ run: ruff format --diff
+
+ - name: lint
+ run: ruff check --diff
diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml
deleted file mode 100644
index 5dde1354a..000000000
--- a/.github/workflows/lock.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-name: 'Lock Threads'
-
-on:
- schedule:
- - cron: '30 9 * * 1'
-
-permissions:
- issues: write
- pull-requests: write
-
-jobs:
- action:
- runs-on: ubuntu-latest
- steps:
- - uses: dessant/lock-threads@v4
-
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 993347f64..6abc96b70 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -8,6 +8,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
+env:
+ FORCE_COLOR: 1
+
jobs:
test:
runs-on: ubuntu-latest
@@ -15,27 +18,31 @@ jobs:
fail-fast: false
matrix:
include:
- - db: "mariadb:10.3"
- py: "3.8"
+ - db: "mariadb:10.4"
+ py: "3.13"
- db: "mariadb:10.5"
- py: "3.7"
-
- - db: "mariadb:10.7"
py: "3.11"
- - db: "mariadb:10.8"
+ - db: "mariadb:10.6"
+ py: "3.10"
+
+ - db: "mariadb:10.6"
py: "3.9"
+ - db: "mariadb:lts"
+ py: "3.8"
+
- db: "mysql:5.7"
- py: "pypy-3.8"
+ py: "pypy-3.10"
- db: "mysql:8.0"
- py: "3.9"
+ py: "3.13"
mysql_auth: true
- - db: "mysql:8.0"
- py: "3.10"
+ - db: "mysql:8.4"
+ py: "3.8"
+ mysql_auth: true
services:
mysql:
@@ -44,12 +51,13 @@ jobs:
- 3306:3306
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
+ MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: yes
options: "--name=mysqld"
volumes:
- /run/mysqld:/run/mysqld
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Workaround MySQL container permissions
if: startsWith(matrix.db, 'mysql')
@@ -58,9 +66,10 @@ jobs:
/usr/bin/docker ps --all --filter status=exited --no-trunc --format "{{.ID}}" | xargs -r /usr/bin/docker start
- name: Set up Python ${{ matrix.py }}
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.py }}
+ allow-prereleases: true
cache: 'pip'
cache-dependency-path: 'requirements-dev.txt'
@@ -94,34 +103,8 @@ jobs:
docker cp mysqld:/var/lib/mysql/server-cert.pem "${HOME}"
docker cp mysqld:/var/lib/mysql/client-key.pem "${HOME}"
docker cp mysqld:/var/lib/mysql/client-cert.pem "${HOME}"
- pytest -v --cov --cov-config .coveragerc tests/test_auth.py;
+ pytest -v --cov-append --cov-config .coveragerc tests/test_auth.py;
- - name: Report coverage
+ - name: Upload coverage reports to Codecov
if: github.repository == 'PyMySQL/PyMySQL'
- run: coveralls --service=github
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- COVERALLS_FLAG_NAME: ${{ matrix.py }}-${{ matrix.db }}
- COVERALLS_PARALLEL: true
-
- coveralls:
- if: github.repository == 'PyMySQL/PyMySQL'
- name: Finish coveralls
- runs-on: ubuntu-latest
- needs: test
- steps:
- - name: requirements.
- run: |
- echo coveralls > requirements.txt
-
- - uses: actions/setup-python@v4
- with:
- python-version: '3.x'
- cache: 'pip'
-
- - name: Finished
- run: |
- pip install --upgrade coveralls
- coveralls --finish --service=github
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ uses: codecov/codecov-action@v5
diff --git a/.gitignore b/.gitignore
index 98f4d45c8..09a5654fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@
/pymysql/tests/databases.json
__pycache__
Pipfile.lock
+pdm.lock
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 000000000..59fdb65df
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,17 @@
+# .readthedocs.yaml
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+version: 2
+
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.12"
+
+python:
+ install:
+ - requirements: docs/requirements.txt
+
+# Build documentation in the docs/ directory with Sphinx
+sphinx:
+ configuration: docs/source/conf.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87c3f9e86..a633f6c51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,58 @@
# Changes
+## Backward incompatible changes planned in the future.
+
+* Error classes in Cursor class will be removed after 2024-06
+* `Connection.set_charset(charset)` will be removed after 2024-06
+* `db` and `passwd` will emit DeprecationWarning in v1.2. See #933.
+* `Connection.ping(reconnect)` change the default to not reconnect.
+
+## v1.1.1
+
+Release date: 2024-05-21
+
+> [!WARNING]
+> This release fixes a vulnerability (CVE-2024-36039).
+> All users are recommended to update to this version.
+>
+> If you can not update soon, check the input value from
+> untrusted source has an expected type. Only dict input
+> from untrusted source can be an attack vector.
+
+* Prohibit dict parameter for `Cursor.execute()`. It didn't produce valid SQL
+ and might cause SQL injection. (CVE-2024-36039)
+* Added ssl_key_password param. #1145
+
+## v1.1.0
+
+Release date: 2023-06-26
+
+* Fixed SSCursor raising OperationalError for query timeouts on wrong statement (#1032)
+* Exposed `Cursor.warning_count` to check for warnings without additional query (#1056)
+* Make Cursor iterator (#995)
+* Support '_' in key name in my.cnf (#1114)
+* `Cursor.fetchall()` returns empty list instead of tuple (#1115). Note that `Cursor.fetchmany()` still return empty tuple after reading all rows for compatibility with Django.
+* Deprecate Error classes in Cursor class (#1117)
+* Add `Connection.set_character_set(charset, collation=None)`. This method is compatible with mysqlclient. (#1119)
+* Deprecate `Connection.set_charset(charset)` (#1119)
+* New connection always send "SET NAMES charset [COLLATE collation]" query. (#1119)
+ Since collation table is vary on MySQL server versions, collation in handshake is fragile.
+* Support `charset="utf8mb3"` option (#1127)
+
+
## v1.0.3
-Release date: TBD
+Release date: 2023-03-28
* Dropped support of end of life MySQL version 5.6
* Dropped support of end of life MariaDB versions below 10.3
* Dropped support of end of life Python version 3.6
+* Removed `_last_executed` because of duplication with `_executed` by @rajat315315 in https://github.com/PyMySQL/PyMySQL/pull/948
+* Fix generating authentication response with long strings by @netch80 in https://github.com/PyMySQL/PyMySQL/pull/988
+* update pymysql.constants.CR by @Nothing4You in https://github.com/PyMySQL/PyMySQL/pull/1029
+* Document that the ssl connection parameter can be an SSLContext by @cakemanny in https://github.com/PyMySQL/PyMySQL/pull/1045
+* Raise ProgrammingError on -np.inf in addition to np.inf by @cdcadman in https://github.com/PyMySQL/PyMySQL/pull/1067
+* Use Python 3.11 release instead of -dev in tests by @Nothing4You in https://github.com/PyMySQL/PyMySQL/pull/1076
## v1.0.2
diff --git a/README.md b/README.md
index dec840803..95e4520a9 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
[](https://pymysql.readthedocs.io/)
-[](https://coveralls.io/github/PyMySQL/PyMySQL?branch=main)
+[](https://codecov.io/gh/PyMySQL/PyMySQL)
# PyMySQL
-This package contains a pure-Python MySQL client library, based on [PEP
-249](https://www.python.org/dev/peps/pep-0249/).
+This package contains a pure-Python MySQL and MariaDB client library, based on
+[PEP 249](https://www.python.org/dev/peps/pep-0249/).
## Requirements
@@ -13,7 +13,7 @@ This package contains a pure-Python MySQL client library, based on [PEP
- [PyPy](https://pypy.org/) : Latest 3.x version
- MySQL Server -- one of the following:
- [MySQL](https://www.mysql.com/) \>= 5.7
- - [MariaDB](https://mariadb.org/) \>= 10.3
+ - [MariaDB](https://mariadb.org/) \>= 10.4
## Installation
@@ -92,6 +92,7 @@ This example will print:
- DB-API 2.0:
- MySQL Reference Manuals:
+- Getting Help With MariaDB
- MySQL client/server protocol:
- "Connector" channel in MySQL Community Slack:
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 000000000..da9c516dd
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,5 @@
+## Security contact information
+
+To report a security vulnerability, please use the
+[Tidelift security contact](https://tidelift.com/security).
+Tidelift will coordinate the fix and disclosure.
diff --git a/ci/test_mysql.py b/ci/test_mysql.py
new file mode 100644
index 000000000..b97978a27
--- /dev/null
+++ b/ci/test_mysql.py
@@ -0,0 +1,47 @@
+# This is an example test settings file for use with the Django test suite.
+#
+# The 'sqlite3' backend requires only the ENGINE setting (an in-
+# memory database will be used). All other backends will require a
+# NAME and potentially authentication information. See the
+# following section in the docs for more information:
+#
+# https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/unit-tests/
+#
+# The different databases that Django supports behave differently in certain
+# situations, so it is recommended to run the test suite against as many
+# database backends as possible. You may want to create a separate settings
+# file for each of the backends you test against.
+
+import pymysql
+
+pymysql.install_as_MySQLdb()
+
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.mysql",
+ "NAME": "django_default",
+ "HOST": "127.0.0.1",
+ "USER": "scott",
+ "PASSWORD": "tiger",
+ "TEST": {"CHARSET": "utf8mb3", "COLLATION": "utf8mb3_general_ci"},
+ },
+ "other": {
+ "ENGINE": "django.db.backends.mysql",
+ "NAME": "django_other",
+ "HOST": "127.0.0.1",
+ "USER": "scott",
+ "PASSWORD": "tiger",
+ "TEST": {"CHARSET": "utf8mb3", "COLLATION": "utf8mb3_general_ci"},
+ },
+}
+
+SECRET_KEY = "django_tests_secret_key"
+
+# Use a fast hasher to speed up tests.
+PASSWORD_HASHERS = [
+ "django.contrib.auth.hashers.MD5PasswordHasher",
+]
+
+DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
+
+USE_TZ = False
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 000000000..919adf200
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,7 @@
+# https://docs.codecov.com/docs/common-recipe-list
+coverage:
+ status:
+ project:
+ default:
+ target: auto
+ threshold: 3%
diff --git a/docs/Makefile b/docs/Makefile
index d37255520..c1240d2ba 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -74,30 +74,6 @@ json:
@echo
@echo "Build finished; now you can process the JSON files."
-htmlhelp:
- $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
- @echo
- @echo "Build finished; now you can run HTML Help Workshop with the" \
- ".hhp project file in $(BUILDDIR)/htmlhelp."
-
-qthelp:
- $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
- @echo
- @echo "Build finished; now you can run "qcollectiongenerator" with the" \
- ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
- @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PyMySQL.qhcp"
- @echo "To view the help file:"
- @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PyMySQL.qhc"
-
-devhelp:
- $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
- @echo
- @echo "Build finished."
- @echo "To view the help file:"
- @echo "# mkdir -p $$HOME/.local/share/devhelp/PyMySQL"
- @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PyMySQL"
- @echo "# devhelp"
-
epub:
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo
diff --git a/docs/make.bat b/docs/make.bat
deleted file mode 100644
index dcd4287c6..000000000
--- a/docs/make.bat
+++ /dev/null
@@ -1,242 +0,0 @@
-@ECHO OFF
-
-REM Command file for Sphinx documentation
-
-if "%SPHINXBUILD%" == "" (
- set SPHINXBUILD=sphinx-build
-)
-set BUILDDIR=build
-set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source
-set I18NSPHINXOPTS=%SPHINXOPTS% source
-if NOT "%PAPER%" == "" (
- set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
- set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
-)
-
-if "%1" == "" goto help
-
-if "%1" == "help" (
- :help
- echo.Please use `make ^` where ^ is one of
- echo. html to make standalone HTML files
- echo. dirhtml to make HTML files named index.html in directories
- echo. singlehtml to make a single large HTML file
- echo. pickle to make pickle files
- echo. json to make JSON files
- echo. htmlhelp to make HTML files and a HTML help project
- echo. qthelp to make HTML files and a qthelp project
- echo. devhelp to make HTML files and a Devhelp project
- echo. epub to make an epub
- echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
- echo. text to make text files
- echo. man to make manual pages
- echo. texinfo to make Texinfo files
- echo. gettext to make PO message catalogs
- echo. changes to make an overview over all changed/added/deprecated items
- echo. xml to make Docutils-native XML files
- echo. pseudoxml to make pseudoxml-XML files for display purposes
- echo. linkcheck to check all external links for integrity
- echo. doctest to run all doctests embedded in the documentation if enabled
- goto end
-)
-
-if "%1" == "clean" (
- for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
- del /q /s %BUILDDIR%\*
- goto end
-)
-
-
-%SPHINXBUILD% 2> nul
-if errorlevel 9009 (
- echo.
- echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
- echo.installed, then set the SPHINXBUILD environment variable to point
- echo.to the full path of the 'sphinx-build' executable. Alternatively you
- echo.may add the Sphinx directory to PATH.
- echo.
- echo.If you don't have Sphinx installed, grab it from
- echo.http://sphinx-doc.org/
- exit /b 1
-)
-
-if "%1" == "html" (
- %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The HTML pages are in %BUILDDIR%/html.
- goto end
-)
-
-if "%1" == "dirhtml" (
- %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
- goto end
-)
-
-if "%1" == "singlehtml" (
- %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
- goto end
-)
-
-if "%1" == "pickle" (
- %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished; now you can process the pickle files.
- goto end
-)
-
-if "%1" == "json" (
- %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished; now you can process the JSON files.
- goto end
-)
-
-if "%1" == "htmlhelp" (
- %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished; now you can run HTML Help Workshop with the ^
-.hhp project file in %BUILDDIR%/htmlhelp.
- goto end
-)
-
-if "%1" == "qthelp" (
- %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished; now you can run "qcollectiongenerator" with the ^
-.qhcp project file in %BUILDDIR%/qthelp, like this:
- echo.^> qcollectiongenerator %BUILDDIR%\qthelp\PyMySQL.qhcp
- echo.To view the help file:
- echo.^> assistant -collectionFile %BUILDDIR%\qthelp\PyMySQL.ghc
- goto end
-)
-
-if "%1" == "devhelp" (
- %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished.
- goto end
-)
-
-if "%1" == "epub" (
- %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The epub file is in %BUILDDIR%/epub.
- goto end
-)
-
-if "%1" == "latex" (
- %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
- goto end
-)
-
-if "%1" == "latexpdf" (
- %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
- cd %BUILDDIR%/latex
- make all-pdf
- cd %BUILDDIR%/..
- echo.
- echo.Build finished; the PDF files are in %BUILDDIR%/latex.
- goto end
-)
-
-if "%1" == "latexpdfja" (
- %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
- cd %BUILDDIR%/latex
- make all-pdf-ja
- cd %BUILDDIR%/..
- echo.
- echo.Build finished; the PDF files are in %BUILDDIR%/latex.
- goto end
-)
-
-if "%1" == "text" (
- %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The text files are in %BUILDDIR%/text.
- goto end
-)
-
-if "%1" == "man" (
- %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The manual pages are in %BUILDDIR%/man.
- goto end
-)
-
-if "%1" == "texinfo" (
- %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
- goto end
-)
-
-if "%1" == "gettext" (
- %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
- goto end
-)
-
-if "%1" == "changes" (
- %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
- if errorlevel 1 exit /b 1
- echo.
- echo.The overview file is in %BUILDDIR%/changes.
- goto end
-)
-
-if "%1" == "linkcheck" (
- %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
- if errorlevel 1 exit /b 1
- echo.
- echo.Link check complete; look for any errors in the above output ^
-or in %BUILDDIR%/linkcheck/output.txt.
- goto end
-)
-
-if "%1" == "doctest" (
- %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
- if errorlevel 1 exit /b 1
- echo.
- echo.Testing of doctests in the sources finished, look at the ^
-results in %BUILDDIR%/doctest/output.txt.
- goto end
-)
-
-if "%1" == "xml" (
- %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The XML files are in %BUILDDIR%/xml.
- goto end
-)
-
-if "%1" == "pseudoxml" (
- %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
- goto end
-)
-
-:end
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 000000000..48319f033
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,2 @@
+sphinx~=8.0
+sphinx-rtd-theme~=3.0.0
diff --git a/docs/source/conf.py b/docs/source/conf.py
index a57a03c44..158d0d12f 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-#
# PyMySQL documentation build configuration file, created by
# sphinx-quickstart on Tue May 17 12:01:11 2016.
#
@@ -30,7 +28,6 @@
# ones.
extensions = [
"sphinx.ext.autodoc",
- "sphinx.ext.intersphinx",
]
# Add any paths that contain templates here, relative to this directory.
@@ -47,7 +44,7 @@
# General information about the project.
project = "PyMySQL"
-copyright = "2016, Yutaka Matsubara and GitHub contributors"
+copyright = "2023, Inada Naoki and GitHub contributors"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
@@ -101,7 +98,7 @@
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
-html_theme = "default"
+html_theme = "sphinx_rtd_theme"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 97633f1aa..e64b64238 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -1,5 +1,5 @@
-Welcome to PyMySQL's documentation!
-===================================
+PyMySQL documentation
+=====================
.. toctree::
:maxdepth: 2
diff --git a/docs/source/user/development.rst b/docs/source/user/development.rst
index 1f8a2637f..2d80a6248 100644
--- a/docs/source/user/development.rst
+++ b/docs/source/user/development.rst
@@ -28,7 +28,7 @@ and edit the new file to match your MySQL configuration::
$ cp ci/database.json pymysql/tests/databases.json
$ $EDITOR pymysql/tests/databases.json
-To run all the tests, execute the script ``runtests.py``::
+To run all the tests, you can use pytest::
- $ pip install pytest
+ $ pip install -r requirements-dev.txt
$ pytest -v pymysql
diff --git a/pymysql/__init__.py b/pymysql/__init__.py
index c0039c3fe..bbf9023ef 100644
--- a/pymysql/__init__.py
+++ b/pymysql/__init__.py
@@ -21,6 +21,7 @@
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
+
import sys
from .constants import FIELD_TYPE
@@ -46,12 +47,31 @@
TimestampFromTicks,
)
+# PyMySQL version.
+# Used by setuptools and connection_attrs
+VERSION = (1, 1, 1, "final", 1)
+VERSION_STRING = "1.1.1"
+
+### for mysqlclient compatibility
+### Django checks mysqlclient version.
+version_info = (1, 4, 6, "final", 1)
+__version__ = "1.4.6"
+
+
+def get_client_info(): # for MySQLdb compatibility
+ return __version__
+
+
+def install_as_MySQLdb():
+ """
+ After this function is called, any application that imports MySQLdb
+ will unwittingly actually use pymysql.
+ """
+ sys.modules["MySQLdb"] = sys.modules["pymysql"]
+
+
+# end of mysqlclient compatibility code
-VERSION = (1, 0, 3)
-if len(VERSION) > 3:
- VERSION_STRING = "%d.%d.%d_%s" % VERSION
-else:
- VERSION_STRING = "%d.%d.%d" % VERSION
threadsafety = 1
apilevel = "2.0"
paramstyle = "pyformat"
@@ -109,31 +129,12 @@ def Binary(x):
return bytes(x)
-Connect = connect = Connection = connections.Connection
-
-
-def get_client_info(): # for MySQLdb compatibility
- return VERSION_STRING
-
-
-# we include a doctored version_info here for MySQLdb compatibility
-version_info = (1, 4, 0, "final", 0)
-
-NULL = "NULL"
-
-__version__ = get_client_info()
-
-
def thread_safe():
return True # match MySQLdb.thread_safe()
-def install_as_MySQLdb():
- """
- After this function is called, any application that imports MySQLdb or
- _mysql will unwittingly actually use pymysql.
- """
- sys.modules["MySQLdb"] = sys.modules["_mysql"] = sys.modules["pymysql"]
+Connect = connect = Connection = connections.Connection
+NULL = "NULL"
__all__ = [
diff --git a/pymysql/_auth.py b/pymysql/_auth.py
index f6c9eb967..4790449b8 100644
--- a/pymysql/_auth.py
+++ b/pymysql/_auth.py
@@ -1,6 +1,7 @@
"""
Implements auth methods
"""
+
from .err import OperationalError
@@ -141,7 +142,8 @@ def sha2_rsa_encrypt(password, salt, public_key):
"""
if not _have_cryptography:
raise RuntimeError(
- "'cryptography' package is required for sha256_password or caching_sha2_password auth methods"
+ "'cryptography' package is required for sha256_password or"
+ + " caching_sha2_password auth methods"
)
message = _xor_password(password + b"\0", salt)
rsa_key = serialization.load_pem_public_key(public_key, default_backend())
@@ -164,6 +166,8 @@ def sha256_password_auth(conn, pkt):
if pkt.is_auth_switch_request():
conn.salt = pkt.read_all()
+ if conn.salt.endswith(b"\0"):
+ conn.salt = conn.salt[:-1]
if not conn.server_public_key and conn.password:
# Request server public key
if DEBUG:
@@ -213,9 +217,11 @@ def caching_sha2_password_auth(conn, pkt):
if pkt.is_auth_switch_request():
# Try from fast auth
- if DEBUG:
- print("caching sha2: Trying fast path")
conn.salt = pkt.read_all()
+ if conn.salt.endswith(b"\0"): # str.removesuffix is available in 3.9
+ conn.salt = conn.salt[:-1]
+ if DEBUG:
+ print(f"caching sha2: Trying fast path. salt={conn.salt.hex()!r}")
scrambled = scramble_caching_sha2(conn.password, conn.salt)
pkt = _roundtrip(conn, scrambled)
# else: fast auth is tried in initial handshake
diff --git a/pymysql/charset.py b/pymysql/charset.py
index ac87c53dd..ec8e14e21 100644
--- a/pymysql/charset.py
+++ b/pymysql/charset.py
@@ -1,16 +1,16 @@
+# Internal use only. Do not use directly.
+
MBLENGTH = {8: 1, 33: 3, 88: 2, 91: 2}
class Charset:
- def __init__(self, id, name, collation, is_default):
+ def __init__(self, id, name, collation, is_default=False):
self.id, self.name, self.collation = id, name, collation
- self.is_default = is_default == "Yes"
+ self.is_default = is_default
def __repr__(self):
- return "Charset(id=%s, name=%r, collation=%r)" % (
- self.id,
- self.name,
- self.collation,
+ return (
+ f"Charset(id={self.id}, name={self.name!r}, collation={self.collation!r})"
)
@property
@@ -45,165 +45,173 @@ def by_id(self, id):
return self._by_id[id]
def by_name(self, name):
- return self._by_name.get(name.lower())
+ name = name.lower()
+ if name == "utf8":
+ name = "utf8mb4"
+ return self._by_name.get(name)
_charsets = Charsets()
+charset_by_name = _charsets.by_name
+charset_by_id = _charsets.by_id
+
"""
+TODO: update this script.
+
Generated with:
mysql -N -s -e "select id, character_set_name, collation_name, is_default
from information_schema.collations order by id;" | python -c "import sys
for l in sys.stdin.readlines():
- id, name, collation, is_default = l.split(chr(9))
- print '_charsets.add(Charset(%s, \'%s\', \'%s\', \'%s\'))' \
- % (id, name, collation, is_default.strip())
-"
-
+ id, name, collation, is_default = l.split(chr(9))
+ if is_default.strip() == "Yes":
+ print('_charsets.add(Charset(%s, \'%s\', \'%s\', True))' \
+ % (id, name, collation))
+ else:
+ print('_charsets.add(Charset(%s, \'%s\', \'%s\'))' \
+ % (id, name, collation, bool(is_default.strip()))
"""
-_charsets.add(Charset(1, "big5", "big5_chinese_ci", "Yes"))
-_charsets.add(Charset(2, "latin2", "latin2_czech_cs", ""))
-_charsets.add(Charset(3, "dec8", "dec8_swedish_ci", "Yes"))
-_charsets.add(Charset(4, "cp850", "cp850_general_ci", "Yes"))
-_charsets.add(Charset(5, "latin1", "latin1_german1_ci", ""))
-_charsets.add(Charset(6, "hp8", "hp8_english_ci", "Yes"))
-_charsets.add(Charset(7, "koi8r", "koi8r_general_ci", "Yes"))
-_charsets.add(Charset(8, "latin1", "latin1_swedish_ci", "Yes"))
-_charsets.add(Charset(9, "latin2", "latin2_general_ci", "Yes"))
-_charsets.add(Charset(10, "swe7", "swe7_swedish_ci", "Yes"))
-_charsets.add(Charset(11, "ascii", "ascii_general_ci", "Yes"))
-_charsets.add(Charset(12, "ujis", "ujis_japanese_ci", "Yes"))
-_charsets.add(Charset(13, "sjis", "sjis_japanese_ci", "Yes"))
-_charsets.add(Charset(14, "cp1251", "cp1251_bulgarian_ci", ""))
-_charsets.add(Charset(15, "latin1", "latin1_danish_ci", ""))
-_charsets.add(Charset(16, "hebrew", "hebrew_general_ci", "Yes"))
-_charsets.add(Charset(18, "tis620", "tis620_thai_ci", "Yes"))
-_charsets.add(Charset(19, "euckr", "euckr_korean_ci", "Yes"))
-_charsets.add(Charset(20, "latin7", "latin7_estonian_cs", ""))
-_charsets.add(Charset(21, "latin2", "latin2_hungarian_ci", ""))
-_charsets.add(Charset(22, "koi8u", "koi8u_general_ci", "Yes"))
-_charsets.add(Charset(23, "cp1251", "cp1251_ukrainian_ci", ""))
-_charsets.add(Charset(24, "gb2312", "gb2312_chinese_ci", "Yes"))
-_charsets.add(Charset(25, "greek", "greek_general_ci", "Yes"))
-_charsets.add(Charset(26, "cp1250", "cp1250_general_ci", "Yes"))
-_charsets.add(Charset(27, "latin2", "latin2_croatian_ci", ""))
-_charsets.add(Charset(28, "gbk", "gbk_chinese_ci", "Yes"))
-_charsets.add(Charset(29, "cp1257", "cp1257_lithuanian_ci", ""))
-_charsets.add(Charset(30, "latin5", "latin5_turkish_ci", "Yes"))
-_charsets.add(Charset(31, "latin1", "latin1_german2_ci", ""))
-_charsets.add(Charset(32, "armscii8", "armscii8_general_ci", "Yes"))
-_charsets.add(Charset(33, "utf8", "utf8_general_ci", "Yes"))
-_charsets.add(Charset(34, "cp1250", "cp1250_czech_cs", ""))
-_charsets.add(Charset(36, "cp866", "cp866_general_ci", "Yes"))
-_charsets.add(Charset(37, "keybcs2", "keybcs2_general_ci", "Yes"))
-_charsets.add(Charset(38, "macce", "macce_general_ci", "Yes"))
-_charsets.add(Charset(39, "macroman", "macroman_general_ci", "Yes"))
-_charsets.add(Charset(40, "cp852", "cp852_general_ci", "Yes"))
-_charsets.add(Charset(41, "latin7", "latin7_general_ci", "Yes"))
-_charsets.add(Charset(42, "latin7", "latin7_general_cs", ""))
-_charsets.add(Charset(43, "macce", "macce_bin", ""))
-_charsets.add(Charset(44, "cp1250", "cp1250_croatian_ci", ""))
-_charsets.add(Charset(45, "utf8mb4", "utf8mb4_general_ci", "Yes"))
-_charsets.add(Charset(46, "utf8mb4", "utf8mb4_bin", ""))
-_charsets.add(Charset(47, "latin1", "latin1_bin", ""))
-_charsets.add(Charset(48, "latin1", "latin1_general_ci", ""))
-_charsets.add(Charset(49, "latin1", "latin1_general_cs", ""))
-_charsets.add(Charset(50, "cp1251", "cp1251_bin", ""))
-_charsets.add(Charset(51, "cp1251", "cp1251_general_ci", "Yes"))
-_charsets.add(Charset(52, "cp1251", "cp1251_general_cs", ""))
-_charsets.add(Charset(53, "macroman", "macroman_bin", ""))
-_charsets.add(Charset(57, "cp1256", "cp1256_general_ci", "Yes"))
-_charsets.add(Charset(58, "cp1257", "cp1257_bin", ""))
-_charsets.add(Charset(59, "cp1257", "cp1257_general_ci", "Yes"))
-_charsets.add(Charset(63, "binary", "binary", "Yes"))
-_charsets.add(Charset(64, "armscii8", "armscii8_bin", ""))
-_charsets.add(Charset(65, "ascii", "ascii_bin", ""))
-_charsets.add(Charset(66, "cp1250", "cp1250_bin", ""))
-_charsets.add(Charset(67, "cp1256", "cp1256_bin", ""))
-_charsets.add(Charset(68, "cp866", "cp866_bin", ""))
-_charsets.add(Charset(69, "dec8", "dec8_bin", ""))
-_charsets.add(Charset(70, "greek", "greek_bin", ""))
-_charsets.add(Charset(71, "hebrew", "hebrew_bin", ""))
-_charsets.add(Charset(72, "hp8", "hp8_bin", ""))
-_charsets.add(Charset(73, "keybcs2", "keybcs2_bin", ""))
-_charsets.add(Charset(74, "koi8r", "koi8r_bin", ""))
-_charsets.add(Charset(75, "koi8u", "koi8u_bin", ""))
-_charsets.add(Charset(76, "utf8", "utf8_tolower_ci", ""))
-_charsets.add(Charset(77, "latin2", "latin2_bin", ""))
-_charsets.add(Charset(78, "latin5", "latin5_bin", ""))
-_charsets.add(Charset(79, "latin7", "latin7_bin", ""))
-_charsets.add(Charset(80, "cp850", "cp850_bin", ""))
-_charsets.add(Charset(81, "cp852", "cp852_bin", ""))
-_charsets.add(Charset(82, "swe7", "swe7_bin", ""))
-_charsets.add(Charset(83, "utf8", "utf8_bin", ""))
-_charsets.add(Charset(84, "big5", "big5_bin", ""))
-_charsets.add(Charset(85, "euckr", "euckr_bin", ""))
-_charsets.add(Charset(86, "gb2312", "gb2312_bin", ""))
-_charsets.add(Charset(87, "gbk", "gbk_bin", ""))
-_charsets.add(Charset(88, "sjis", "sjis_bin", ""))
-_charsets.add(Charset(89, "tis620", "tis620_bin", ""))
-_charsets.add(Charset(91, "ujis", "ujis_bin", ""))
-_charsets.add(Charset(92, "geostd8", "geostd8_general_ci", "Yes"))
-_charsets.add(Charset(93, "geostd8", "geostd8_bin", ""))
-_charsets.add(Charset(94, "latin1", "latin1_spanish_ci", ""))
-_charsets.add(Charset(95, "cp932", "cp932_japanese_ci", "Yes"))
-_charsets.add(Charset(96, "cp932", "cp932_bin", ""))
-_charsets.add(Charset(97, "eucjpms", "eucjpms_japanese_ci", "Yes"))
-_charsets.add(Charset(98, "eucjpms", "eucjpms_bin", ""))
-_charsets.add(Charset(99, "cp1250", "cp1250_polish_ci", ""))
-_charsets.add(Charset(192, "utf8", "utf8_unicode_ci", ""))
-_charsets.add(Charset(193, "utf8", "utf8_icelandic_ci", ""))
-_charsets.add(Charset(194, "utf8", "utf8_latvian_ci", ""))
-_charsets.add(Charset(195, "utf8", "utf8_romanian_ci", ""))
-_charsets.add(Charset(196, "utf8", "utf8_slovenian_ci", ""))
-_charsets.add(Charset(197, "utf8", "utf8_polish_ci", ""))
-_charsets.add(Charset(198, "utf8", "utf8_estonian_ci", ""))
-_charsets.add(Charset(199, "utf8", "utf8_spanish_ci", ""))
-_charsets.add(Charset(200, "utf8", "utf8_swedish_ci", ""))
-_charsets.add(Charset(201, "utf8", "utf8_turkish_ci", ""))
-_charsets.add(Charset(202, "utf8", "utf8_czech_ci", ""))
-_charsets.add(Charset(203, "utf8", "utf8_danish_ci", ""))
-_charsets.add(Charset(204, "utf8", "utf8_lithuanian_ci", ""))
-_charsets.add(Charset(205, "utf8", "utf8_slovak_ci", ""))
-_charsets.add(Charset(206, "utf8", "utf8_spanish2_ci", ""))
-_charsets.add(Charset(207, "utf8", "utf8_roman_ci", ""))
-_charsets.add(Charset(208, "utf8", "utf8_persian_ci", ""))
-_charsets.add(Charset(209, "utf8", "utf8_esperanto_ci", ""))
-_charsets.add(Charset(210, "utf8", "utf8_hungarian_ci", ""))
-_charsets.add(Charset(211, "utf8", "utf8_sinhala_ci", ""))
-_charsets.add(Charset(212, "utf8", "utf8_german2_ci", ""))
-_charsets.add(Charset(213, "utf8", "utf8_croatian_ci", ""))
-_charsets.add(Charset(214, "utf8", "utf8_unicode_520_ci", ""))
-_charsets.add(Charset(215, "utf8", "utf8_vietnamese_ci", ""))
-_charsets.add(Charset(223, "utf8", "utf8_general_mysql500_ci", ""))
-_charsets.add(Charset(224, "utf8mb4", "utf8mb4_unicode_ci", ""))
-_charsets.add(Charset(225, "utf8mb4", "utf8mb4_icelandic_ci", ""))
-_charsets.add(Charset(226, "utf8mb4", "utf8mb4_latvian_ci", ""))
-_charsets.add(Charset(227, "utf8mb4", "utf8mb4_romanian_ci", ""))
-_charsets.add(Charset(228, "utf8mb4", "utf8mb4_slovenian_ci", ""))
-_charsets.add(Charset(229, "utf8mb4", "utf8mb4_polish_ci", ""))
-_charsets.add(Charset(230, "utf8mb4", "utf8mb4_estonian_ci", ""))
-_charsets.add(Charset(231, "utf8mb4", "utf8mb4_spanish_ci", ""))
-_charsets.add(Charset(232, "utf8mb4", "utf8mb4_swedish_ci", ""))
-_charsets.add(Charset(233, "utf8mb4", "utf8mb4_turkish_ci", ""))
-_charsets.add(Charset(234, "utf8mb4", "utf8mb4_czech_ci", ""))
-_charsets.add(Charset(235, "utf8mb4", "utf8mb4_danish_ci", ""))
-_charsets.add(Charset(236, "utf8mb4", "utf8mb4_lithuanian_ci", ""))
-_charsets.add(Charset(237, "utf8mb4", "utf8mb4_slovak_ci", ""))
-_charsets.add(Charset(238, "utf8mb4", "utf8mb4_spanish2_ci", ""))
-_charsets.add(Charset(239, "utf8mb4", "utf8mb4_roman_ci", ""))
-_charsets.add(Charset(240, "utf8mb4", "utf8mb4_persian_ci", ""))
-_charsets.add(Charset(241, "utf8mb4", "utf8mb4_esperanto_ci", ""))
-_charsets.add(Charset(242, "utf8mb4", "utf8mb4_hungarian_ci", ""))
-_charsets.add(Charset(243, "utf8mb4", "utf8mb4_sinhala_ci", ""))
-_charsets.add(Charset(244, "utf8mb4", "utf8mb4_german2_ci", ""))
-_charsets.add(Charset(245, "utf8mb4", "utf8mb4_croatian_ci", ""))
-_charsets.add(Charset(246, "utf8mb4", "utf8mb4_unicode_520_ci", ""))
-_charsets.add(Charset(247, "utf8mb4", "utf8mb4_vietnamese_ci", ""))
-_charsets.add(Charset(248, "gb18030", "gb18030_chinese_ci", "Yes"))
-_charsets.add(Charset(249, "gb18030", "gb18030_bin", ""))
-_charsets.add(Charset(250, "gb18030", "gb18030_unicode_520_ci", ""))
-_charsets.add(Charset(255, "utf8mb4", "utf8mb4_0900_ai_ci", ""))
-charset_by_name = _charsets.by_name
-charset_by_id = _charsets.by_id
+_charsets.add(Charset(1, "big5", "big5_chinese_ci", True))
+_charsets.add(Charset(2, "latin2", "latin2_czech_cs"))
+_charsets.add(Charset(3, "dec8", "dec8_swedish_ci", True))
+_charsets.add(Charset(4, "cp850", "cp850_general_ci", True))
+_charsets.add(Charset(5, "latin1", "latin1_german1_ci"))
+_charsets.add(Charset(6, "hp8", "hp8_english_ci", True))
+_charsets.add(Charset(7, "koi8r", "koi8r_general_ci", True))
+_charsets.add(Charset(8, "latin1", "latin1_swedish_ci", True))
+_charsets.add(Charset(9, "latin2", "latin2_general_ci", True))
+_charsets.add(Charset(10, "swe7", "swe7_swedish_ci", True))
+_charsets.add(Charset(11, "ascii", "ascii_general_ci", True))
+_charsets.add(Charset(12, "ujis", "ujis_japanese_ci", True))
+_charsets.add(Charset(13, "sjis", "sjis_japanese_ci", True))
+_charsets.add(Charset(14, "cp1251", "cp1251_bulgarian_ci"))
+_charsets.add(Charset(15, "latin1", "latin1_danish_ci"))
+_charsets.add(Charset(16, "hebrew", "hebrew_general_ci", True))
+_charsets.add(Charset(18, "tis620", "tis620_thai_ci", True))
+_charsets.add(Charset(19, "euckr", "euckr_korean_ci", True))
+_charsets.add(Charset(20, "latin7", "latin7_estonian_cs"))
+_charsets.add(Charset(21, "latin2", "latin2_hungarian_ci"))
+_charsets.add(Charset(22, "koi8u", "koi8u_general_ci", True))
+_charsets.add(Charset(23, "cp1251", "cp1251_ukrainian_ci"))
+_charsets.add(Charset(24, "gb2312", "gb2312_chinese_ci", True))
+_charsets.add(Charset(25, "greek", "greek_general_ci", True))
+_charsets.add(Charset(26, "cp1250", "cp1250_general_ci", True))
+_charsets.add(Charset(27, "latin2", "latin2_croatian_ci"))
+_charsets.add(Charset(28, "gbk", "gbk_chinese_ci", True))
+_charsets.add(Charset(29, "cp1257", "cp1257_lithuanian_ci"))
+_charsets.add(Charset(30, "latin5", "latin5_turkish_ci", True))
+_charsets.add(Charset(31, "latin1", "latin1_german2_ci"))
+_charsets.add(Charset(32, "armscii8", "armscii8_general_ci", True))
+_charsets.add(Charset(33, "utf8mb3", "utf8mb3_general_ci", True))
+_charsets.add(Charset(34, "cp1250", "cp1250_czech_cs"))
+_charsets.add(Charset(36, "cp866", "cp866_general_ci", True))
+_charsets.add(Charset(37, "keybcs2", "keybcs2_general_ci", True))
+_charsets.add(Charset(38, "macce", "macce_general_ci", True))
+_charsets.add(Charset(39, "macroman", "macroman_general_ci", True))
+_charsets.add(Charset(40, "cp852", "cp852_general_ci", True))
+_charsets.add(Charset(41, "latin7", "latin7_general_ci", True))
+_charsets.add(Charset(42, "latin7", "latin7_general_cs"))
+_charsets.add(Charset(43, "macce", "macce_bin"))
+_charsets.add(Charset(44, "cp1250", "cp1250_croatian_ci"))
+_charsets.add(Charset(45, "utf8mb4", "utf8mb4_general_ci", True))
+_charsets.add(Charset(46, "utf8mb4", "utf8mb4_bin"))
+_charsets.add(Charset(47, "latin1", "latin1_bin"))
+_charsets.add(Charset(48, "latin1", "latin1_general_ci"))
+_charsets.add(Charset(49, "latin1", "latin1_general_cs"))
+_charsets.add(Charset(50, "cp1251", "cp1251_bin"))
+_charsets.add(Charset(51, "cp1251", "cp1251_general_ci", True))
+_charsets.add(Charset(52, "cp1251", "cp1251_general_cs"))
+_charsets.add(Charset(53, "macroman", "macroman_bin"))
+_charsets.add(Charset(57, "cp1256", "cp1256_general_ci", True))
+_charsets.add(Charset(58, "cp1257", "cp1257_bin"))
+_charsets.add(Charset(59, "cp1257", "cp1257_general_ci", True))
+_charsets.add(Charset(63, "binary", "binary", True))
+_charsets.add(Charset(64, "armscii8", "armscii8_bin"))
+_charsets.add(Charset(65, "ascii", "ascii_bin"))
+_charsets.add(Charset(66, "cp1250", "cp1250_bin"))
+_charsets.add(Charset(67, "cp1256", "cp1256_bin"))
+_charsets.add(Charset(68, "cp866", "cp866_bin"))
+_charsets.add(Charset(69, "dec8", "dec8_bin"))
+_charsets.add(Charset(70, "greek", "greek_bin"))
+_charsets.add(Charset(71, "hebrew", "hebrew_bin"))
+_charsets.add(Charset(72, "hp8", "hp8_bin"))
+_charsets.add(Charset(73, "keybcs2", "keybcs2_bin"))
+_charsets.add(Charset(74, "koi8r", "koi8r_bin"))
+_charsets.add(Charset(75, "koi8u", "koi8u_bin"))
+_charsets.add(Charset(76, "utf8mb3", "utf8mb3_tolower_ci"))
+_charsets.add(Charset(77, "latin2", "latin2_bin"))
+_charsets.add(Charset(78, "latin5", "latin5_bin"))
+_charsets.add(Charset(79, "latin7", "latin7_bin"))
+_charsets.add(Charset(80, "cp850", "cp850_bin"))
+_charsets.add(Charset(81, "cp852", "cp852_bin"))
+_charsets.add(Charset(82, "swe7", "swe7_bin"))
+_charsets.add(Charset(83, "utf8mb3", "utf8mb3_bin"))
+_charsets.add(Charset(84, "big5", "big5_bin"))
+_charsets.add(Charset(85, "euckr", "euckr_bin"))
+_charsets.add(Charset(86, "gb2312", "gb2312_bin"))
+_charsets.add(Charset(87, "gbk", "gbk_bin"))
+_charsets.add(Charset(88, "sjis", "sjis_bin"))
+_charsets.add(Charset(89, "tis620", "tis620_bin"))
+_charsets.add(Charset(91, "ujis", "ujis_bin"))
+_charsets.add(Charset(92, "geostd8", "geostd8_general_ci", True))
+_charsets.add(Charset(93, "geostd8", "geostd8_bin"))
+_charsets.add(Charset(94, "latin1", "latin1_spanish_ci"))
+_charsets.add(Charset(95, "cp932", "cp932_japanese_ci", True))
+_charsets.add(Charset(96, "cp932", "cp932_bin"))
+_charsets.add(Charset(97, "eucjpms", "eucjpms_japanese_ci", True))
+_charsets.add(Charset(98, "eucjpms", "eucjpms_bin"))
+_charsets.add(Charset(99, "cp1250", "cp1250_polish_ci"))
+_charsets.add(Charset(192, "utf8mb3", "utf8mb3_unicode_ci"))
+_charsets.add(Charset(193, "utf8mb3", "utf8mb3_icelandic_ci"))
+_charsets.add(Charset(194, "utf8mb3", "utf8mb3_latvian_ci"))
+_charsets.add(Charset(195, "utf8mb3", "utf8mb3_romanian_ci"))
+_charsets.add(Charset(196, "utf8mb3", "utf8mb3_slovenian_ci"))
+_charsets.add(Charset(197, "utf8mb3", "utf8mb3_polish_ci"))
+_charsets.add(Charset(198, "utf8mb3", "utf8mb3_estonian_ci"))
+_charsets.add(Charset(199, "utf8mb3", "utf8mb3_spanish_ci"))
+_charsets.add(Charset(200, "utf8mb3", "utf8mb3_swedish_ci"))
+_charsets.add(Charset(201, "utf8mb3", "utf8mb3_turkish_ci"))
+_charsets.add(Charset(202, "utf8mb3", "utf8mb3_czech_ci"))
+_charsets.add(Charset(203, "utf8mb3", "utf8mb3_danish_ci"))
+_charsets.add(Charset(204, "utf8mb3", "utf8mb3_lithuanian_ci"))
+_charsets.add(Charset(205, "utf8mb3", "utf8mb3_slovak_ci"))
+_charsets.add(Charset(206, "utf8mb3", "utf8mb3_spanish2_ci"))
+_charsets.add(Charset(207, "utf8mb3", "utf8mb3_roman_ci"))
+_charsets.add(Charset(208, "utf8mb3", "utf8mb3_persian_ci"))
+_charsets.add(Charset(209, "utf8mb3", "utf8mb3_esperanto_ci"))
+_charsets.add(Charset(210, "utf8mb3", "utf8mb3_hungarian_ci"))
+_charsets.add(Charset(211, "utf8mb3", "utf8mb3_sinhala_ci"))
+_charsets.add(Charset(212, "utf8mb3", "utf8mb3_german2_ci"))
+_charsets.add(Charset(213, "utf8mb3", "utf8mb3_croatian_ci"))
+_charsets.add(Charset(214, "utf8mb3", "utf8mb3_unicode_520_ci"))
+_charsets.add(Charset(215, "utf8mb3", "utf8mb3_vietnamese_ci"))
+_charsets.add(Charset(223, "utf8mb3", "utf8mb3_general_mysql500_ci"))
+_charsets.add(Charset(224, "utf8mb4", "utf8mb4_unicode_ci"))
+_charsets.add(Charset(225, "utf8mb4", "utf8mb4_icelandic_ci"))
+_charsets.add(Charset(226, "utf8mb4", "utf8mb4_latvian_ci"))
+_charsets.add(Charset(227, "utf8mb4", "utf8mb4_romanian_ci"))
+_charsets.add(Charset(228, "utf8mb4", "utf8mb4_slovenian_ci"))
+_charsets.add(Charset(229, "utf8mb4", "utf8mb4_polish_ci"))
+_charsets.add(Charset(230, "utf8mb4", "utf8mb4_estonian_ci"))
+_charsets.add(Charset(231, "utf8mb4", "utf8mb4_spanish_ci"))
+_charsets.add(Charset(232, "utf8mb4", "utf8mb4_swedish_ci"))
+_charsets.add(Charset(233, "utf8mb4", "utf8mb4_turkish_ci"))
+_charsets.add(Charset(234, "utf8mb4", "utf8mb4_czech_ci"))
+_charsets.add(Charset(235, "utf8mb4", "utf8mb4_danish_ci"))
+_charsets.add(Charset(236, "utf8mb4", "utf8mb4_lithuanian_ci"))
+_charsets.add(Charset(237, "utf8mb4", "utf8mb4_slovak_ci"))
+_charsets.add(Charset(238, "utf8mb4", "utf8mb4_spanish2_ci"))
+_charsets.add(Charset(239, "utf8mb4", "utf8mb4_roman_ci"))
+_charsets.add(Charset(240, "utf8mb4", "utf8mb4_persian_ci"))
+_charsets.add(Charset(241, "utf8mb4", "utf8mb4_esperanto_ci"))
+_charsets.add(Charset(242, "utf8mb4", "utf8mb4_hungarian_ci"))
+_charsets.add(Charset(243, "utf8mb4", "utf8mb4_sinhala_ci"))
+_charsets.add(Charset(244, "utf8mb4", "utf8mb4_german2_ci"))
+_charsets.add(Charset(245, "utf8mb4", "utf8mb4_croatian_ci"))
+_charsets.add(Charset(246, "utf8mb4", "utf8mb4_unicode_520_ci"))
+_charsets.add(Charset(247, "utf8mb4", "utf8mb4_vietnamese_ci"))
+_charsets.add(Charset(248, "gb18030", "gb18030_chinese_ci", True))
+_charsets.add(Charset(249, "gb18030", "gb18030_bin"))
+_charsets.add(Charset(250, "gb18030", "gb18030_unicode_520_ci"))
+_charsets.add(Charset(255, "utf8mb4", "utf8mb4_0900_ai_ci"))
diff --git a/pymysql/connections.py b/pymysql/connections.py
index 3265d32ee..99fcfcd0b 100644
--- a/pymysql/connections.py
+++ b/pymysql/connections.py
@@ -40,11 +40,14 @@
DEFAULT_USER = getpass.getuser()
del getpass
-except (ImportError, KeyError):
- # KeyError occurs when there's no entry in OS database for a current user.
+except (ImportError, KeyError, OSError):
+ # When there's no entry in OS database for a current user:
+ # KeyError is raised in Python 3.12 and below.
+ # OSError is raised in Python 3.13+
DEFAULT_USER = None
DEBUG = False
+_DEFAULT_AUTH_PLUGIN = None # if this is not None, use it instead of server's default.
TEXT_TYPES = {
FIELD_TYPE.BIT,
@@ -84,8 +87,7 @@ def _lenenc_int(i):
return b"\xfe" + struct.pack(" len(self._data):
raise Exception(
- "Invalid advance amount (%s) for cursor. "
- "Position=%s" % (length, new_position)
+ f"Invalid advance amount ({length}) for cursor. Position={new_position}"
)
self._position = new_position
@@ -275,7 +273,7 @@ def get_column_length(self):
return self.length
def __str__(self):
- return "%s %r.%r.%r, type=%s, flags=%x" % (
+ return "{} {!r}.{!r}.{!r}, type={}, flags={:x}".format(
self.__class__,
self.db,
self.table_name,
diff --git a/pymysql/tests/__init__.py b/pymysql/tests/__init__.py
index fe3b1d0f5..e69de29bb 100644
--- a/pymysql/tests/__init__.py
+++ b/pymysql/tests/__init__.py
@@ -1,19 +0,0 @@
-# Sorted by alphabetical order
-from pymysql.tests.test_DictCursor import *
-from pymysql.tests.test_SSCursor import *
-from pymysql.tests.test_basic import *
-from pymysql.tests.test_connection import *
-from pymysql.tests.test_converters import *
-from pymysql.tests.test_cursor import *
-from pymysql.tests.test_err import *
-from pymysql.tests.test_issues import *
-from pymysql.tests.test_load_local import *
-from pymysql.tests.test_nextset import *
-from pymysql.tests.test_optionfile import *
-
-from pymysql.tests.thirdparty import *
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/pymysql/tests/base.py b/pymysql/tests/base.py
index a87307a57..6dfa9590a 100644
--- a/pymysql/tests/base.py
+++ b/pymysql/tests/base.py
@@ -1,4 +1,3 @@
-import gc
import json
import os
import re
@@ -49,6 +48,14 @@ def mysql_server_is(self, conn, version_tuple):
)
return server_version_tuple >= version_tuple
+ def get_mysql_vendor(self, conn):
+ server_version = conn.get_server_info()
+
+ if "MariaDB" in server_version:
+ return "mariadb"
+
+ return "mysql"
+
_connections = None
@property
@@ -91,7 +98,7 @@ def safe_create_table(self, connection, tablename, ddl, cleanup=True):
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- cursor.execute("drop table if exists `%s`" % (tablename,))
+ cursor.execute(f"drop table if exists `{tablename}`")
cursor.execute(ddl)
cursor.close()
if cleanup:
@@ -101,5 +108,5 @@ def drop_table(self, connection, tablename):
cursor = connection.cursor()
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- cursor.execute("drop table if exists `%s`" % (tablename,))
+ cursor.execute(f"drop table if exists `{tablename}`")
cursor.close()
diff --git a/pymysql/tests/test_DictCursor.py b/pymysql/tests/test_DictCursor.py
index bbc87d032..4e545792a 100644
--- a/pymysql/tests/test_DictCursor.py
+++ b/pymysql/tests/test_DictCursor.py
@@ -13,7 +13,7 @@ class TestDictCursor(base.PyMySQLTestCase):
cursor_type = pymysql.cursors.DictCursor
def setUp(self):
- super(TestDictCursor, self).setUp()
+ super().setUp()
self.conn = conn = self.connect()
c = conn.cursor(self.cursor_type)
@@ -36,7 +36,7 @@ def setUp(self):
def tearDown(self):
c = self.conn.cursor()
c.execute("drop table dictcursor")
- super(TestDictCursor, self).tearDown()
+ super().tearDown()
def _ensure_cursor_expired(self, cursor):
pass
diff --git a/pymysql/tests/test_SSCursor.py b/pymysql/tests/test_SSCursor.py
index a68a77698..d5e6e2bce 100644
--- a/pymysql/tests/test_SSCursor.py
+++ b/pymysql/tests/test_SSCursor.py
@@ -1,15 +1,8 @@
-import sys
+import pytest
-try:
- from pymysql.tests import base
- import pymysql.cursors
- from pymysql.constants import CLIENT
-except Exception:
- # For local testing from top-level directory, without installing
- sys.path.append("../pymysql")
- from pymysql.tests import base
- import pymysql.cursors
- from pymysql.constants import CLIENT
+from pymysql.tests import base
+import pymysql.cursors
+from pymysql.constants import CLIENT, ER
class TestSSCursor(base.PyMySQLTestCase):
@@ -34,10 +27,7 @@ def test_SSCursor(self):
# Create table
cursor.execute(
- "CREATE TABLE tz_data ("
- "region VARCHAR(64),"
- "zone VARCHAR(64),"
- "name VARCHAR(64))"
+ "CREATE TABLE tz_data (region VARCHAR(64), zone VARCHAR(64), name VARCHAR(64))"
)
conn.begin()
@@ -122,6 +112,121 @@ def test_SSCursor(self):
cursor.execute("DROP TABLE IF EXISTS tz_data")
cursor.close()
+ def test_execution_time_limit(self):
+ # this method is similarly implemented in test_cursor
+
+ conn = self.connect()
+
+ # table creation and filling is SSCursor only as it's not provided by self.setUp()
+ self.safe_create_table(
+ conn,
+ "test",
+ "create table test (data varchar(10))",
+ )
+ with conn.cursor() as cur:
+ cur.execute(
+ "insert into test (data) values "
+ "('row1'), ('row2'), ('row3'), ('row4'), ('row5')"
+ )
+ conn.commit()
+
+ db_type = self.get_mysql_vendor(conn)
+
+ with conn.cursor(pymysql.cursors.SSCursor) as cur:
+ # MySQL MAX_EXECUTION_TIME takes ms
+ # MariaDB max_statement_time takes seconds as int/float, introduced in 10.1
+
+ # this will sleep 0.01 seconds per row
+ if db_type == "mysql":
+ sql = (
+ "SELECT /*+ MAX_EXECUTION_TIME(2000) */ data, sleep(0.01) FROM test"
+ )
+ else:
+ sql = "SET STATEMENT max_statement_time=2 FOR SELECT data, sleep(0.01) FROM test"
+
+ cur.execute(sql)
+ # unlike Cursor, SSCursor returns a list of tuples here
+ self.assertEqual(
+ cur.fetchall(),
+ [
+ ("row1", 0),
+ ("row2", 0),
+ ("row3", 0),
+ ("row4", 0),
+ ("row5", 0),
+ ],
+ )
+
+ if db_type == "mysql":
+ sql = (
+ "SELECT /*+ MAX_EXECUTION_TIME(2000) */ data, sleep(0.01) FROM test"
+ )
+ else:
+ sql = "SET STATEMENT max_statement_time=2 FOR SELECT data, sleep(0.01) FROM test"
+ cur.execute(sql)
+ self.assertEqual(cur.fetchone(), ("row1", 0))
+
+ # this discards the previous unfinished query and raises an
+ # incomplete unbuffered query warning
+ with pytest.warns(UserWarning):
+ cur.execute("SELECT 1")
+ self.assertEqual(cur.fetchone(), (1,))
+
+ # SSCursor will not read the EOF packet until we try to read
+ # another row. Skipping this will raise an incomplete unbuffered
+ # query warning in the next cur.execute().
+ self.assertEqual(cur.fetchone(), None)
+
+ if db_type == "mysql":
+ sql = "SELECT /*+ MAX_EXECUTION_TIME(1) */ data, sleep(1) FROM test"
+ else:
+ sql = "SET STATEMENT max_statement_time=0.001 FOR SELECT data, sleep(1) FROM test"
+ with pytest.raises(pymysql.err.OperationalError) as cm:
+ # in an unbuffered cursor the OperationalError may not show up
+ # until fetching the entire result
+ cur.execute(sql)
+ cur.fetchall()
+
+ if db_type == "mysql":
+ # this constant was only introduced in MySQL 5.7, not sure
+ # what was returned before, may have been ER_QUERY_INTERRUPTED
+ self.assertEqual(cm.value.args[0], ER.QUERY_TIMEOUT)
+ else:
+ self.assertEqual(cm.value.args[0], ER.STATEMENT_TIMEOUT)
+
+ # connection should still be fine at this point
+ cur.execute("SELECT 1")
+ self.assertEqual(cur.fetchone(), (1,))
+
+ def test_warnings(self):
+ con = self.connect()
+ cur = con.cursor(pymysql.cursors.SSCursor)
+ cur.execute("DROP TABLE IF EXISTS `no_exists_table`")
+ self.assertEqual(cur.warning_count, 1)
+
+ cur.execute("SHOW WARNINGS")
+ w = cur.fetchone()
+ self.assertEqual(w[1], ER.BAD_TABLE_ERROR)
+ self.assertIn(
+ "no_exists_table",
+ w[2],
+ )
+
+ # ensure unbuffered result is finished
+ self.assertIsNone(cur.fetchone())
+
+ cur.execute("SELECT 1")
+ self.assertEqual(cur.fetchone(), (1,))
+ self.assertIsNone(cur.fetchone())
+
+ self.assertEqual(cur.warning_count, 0)
+
+ cur.execute("SELECT CAST('abc' AS SIGNED)")
+ # this ensures fully retrieving the unbuffered result
+ rows = cur.fetchmany(2)
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(cur.warning_count, 1)
+
__all__ = ["TestSSCursor"]
diff --git a/pymysql/tests/test_basic.py b/pymysql/tests/test_basic.py
index 8af07da09..0fe13b59d 100644
--- a/pymysql/tests/test_basic.py
+++ b/pymysql/tests/test_basic.py
@@ -6,7 +6,6 @@
import pymysql.cursors
from pymysql.tests import base
-from pymysql.err import ProgrammingError
__all__ = ["TestConversion", "TestCursor", "TestBulkInserts"]
@@ -18,7 +17,22 @@ def test_datatypes(self):
conn = self.connect()
c = conn.cursor()
c.execute(
- "create table test_datatypes (b bit, i int, l bigint, f real, s varchar(32), u varchar(32), bb blob, d date, dt datetime, ts timestamp, td time, t time, st datetime)"
+ """
+create table test_datatypes (
+ b bit,
+ i int,
+ l bigint,
+ f real,
+ s varchar(32),
+ u varchar(32),
+ bb blob,
+ d date,
+ dt datetime,
+ ts timestamp,
+ td time,
+ t time,
+ st datetime)
+"""
)
try:
# insert values
@@ -38,7 +52,8 @@ def test_datatypes(self):
time.localtime(),
)
c.execute(
- "insert into test_datatypes (b,i,l,f,s,u,bb,d,dt,td,t,st) values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
+ "insert into test_datatypes (b,i,l,f,s,u,bb,d,dt,td,t,st) values"
+ " (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
v,
)
c.execute("select b,i,l,f,s,u,bb,d,dt,td,t,st from test_datatypes")
@@ -54,7 +69,8 @@ def test_datatypes(self):
# check nulls
c.execute(
- "insert into test_datatypes (b,i,l,f,s,u,bb,d,dt,td,t,st) values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
+ "insert into test_datatypes (b,i,l,f,s,u,bb,d,dt,td,t,st)"
+ " values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
[None] * 12,
)
c.execute("select b,i,l,f,s,u,bb,d,dt,td,t,st from test_datatypes")
@@ -156,7 +172,8 @@ def test_timedelta(self):
conn = self.connect()
c = conn.cursor()
c.execute(
- "select time('12:30'), time('23:12:59'), time('23:12:59.05100'), time('-12:30'), time('-23:12:59'), time('-23:12:59.05100'), time('-00:30')"
+ "select time('12:30'), time('23:12:59'), time('23:12:59.05100'),"
+ + " time('-12:30'), time('-23:12:59'), time('-23:12:59.05100'), time('-00:30')"
)
self.assertEqual(
(
@@ -306,18 +323,18 @@ def test_json(self):
res = cur.fetchone()[0]
self.assertEqual(json.loads(res), json.loads(json_str))
- cur.execute("SELECT CAST(%s AS JSON) AS x", (json_str,))
- res = cur.fetchone()[0]
- self.assertEqual(json.loads(res), json.loads(json_str))
+ if self.get_mysql_vendor(conn) == "mysql":
+ cur.execute("SELECT CAST(%s AS JSON) AS x", (json_str,))
+ res = cur.fetchone()[0]
+ self.assertEqual(json.loads(res), json.loads(json_str))
class TestBulkInserts(base.PyMySQLTestCase):
cursor_type = pymysql.cursors.DictCursor
def setUp(self):
- super(TestBulkInserts, self).setUp()
+ super().setUp()
self.conn = conn = self.connect()
- c = conn.cursor(self.cursor_type)
# create a table and some data to query
self.safe_create_table(
@@ -348,7 +365,7 @@ def test_bulk_insert(self):
data = [(0, "bob", 21, 123), (1, "jim", 56, 45), (2, "fred", 100, 180)]
cursor.executemany(
- "insert into bulkinsert (id, name, age, height) " "values (%s,%s,%s,%s)",
+ "insert into bulkinsert (id, name, age, height) values (%s,%s,%s,%s)",
data,
)
self.assertEqual(
@@ -398,14 +415,14 @@ def test_bulk_insert_single_record(self):
cursor = conn.cursor()
data = [(0, "bob", 21, 123)]
cursor.executemany(
- "insert into bulkinsert (id, name, age, height) " "values (%s,%s,%s,%s)",
+ "insert into bulkinsert (id, name, age, height) values (%s,%s,%s,%s)",
data,
)
cursor.execute("commit")
self._verify_records(data)
def test_issue_288(self):
- """executemany should work with "insert ... on update" """
+ """executemany should work with "insert ... on update"""
conn = self.connect()
cursor = conn.cursor()
data = [(0, "bob", 21, 123), (1, "jim", 56, 45), (2, "fred", 100, 180)]
diff --git a/pymysql/tests/test_charset.py b/pymysql/tests/test_charset.py
new file mode 100644
index 000000000..1dbe6fffa
--- /dev/null
+++ b/pymysql/tests/test_charset.py
@@ -0,0 +1,44 @@
+import pymysql.charset
+
+
+def test_utf8():
+ utf8mb3 = pymysql.charset.charset_by_name("utf8mb3")
+ assert utf8mb3.name == "utf8mb3"
+ assert utf8mb3.collation == "utf8mb3_general_ci"
+ assert (
+ repr(utf8mb3)
+ == "Charset(id=33, name='utf8mb3', collation='utf8mb3_general_ci')"
+ )
+
+ # MySQL 8.0 changed the default collation for utf8mb4.
+ # But we use old default for compatibility.
+ utf8mb4 = pymysql.charset.charset_by_name("utf8mb4")
+ assert utf8mb4.name == "utf8mb4"
+ assert utf8mb4.collation == "utf8mb4_general_ci"
+ assert (
+ repr(utf8mb4)
+ == "Charset(id=45, name='utf8mb4', collation='utf8mb4_general_ci')"
+ )
+
+ # utf8 is alias of utf8mb4 since MySQL 8.0, and PyMySQL v1.1.
+ lowercase_utf8 = pymysql.charset.charset_by_name("utf8")
+ assert lowercase_utf8 == utf8mb4
+
+ # Regardless of case, UTF8 (which is special cased) should resolve to the same thing
+ uppercase_utf8 = pymysql.charset.charset_by_name("UTF8")
+ mixedcase_utf8 = pymysql.charset.charset_by_name("UtF8")
+ assert uppercase_utf8 == lowercase_utf8
+ assert mixedcase_utf8 == lowercase_utf8
+
+
+def test_case_sensitivity():
+ lowercase_latin1 = pymysql.charset.charset_by_name("latin1")
+ assert lowercase_latin1 is not None
+
+ # lowercase and uppercase should resolve to the same charset
+ uppercase_latin1 = pymysql.charset.charset_by_name("LATIN1")
+ assert uppercase_latin1 == lowercase_latin1
+
+ # lowercase and mixed case should resolve to the same charset
+ mixedcase_latin1 = pymysql.charset.charset_by_name("LaTiN1")
+ assert mixedcase_latin1 == lowercase_latin1
diff --git a/pymysql/tests/test_connection.py b/pymysql/tests/test_connection.py
index d6fb5e523..1a16c982a 100644
--- a/pymysql/tests/test_connection.py
+++ b/pymysql/tests/test_connection.py
@@ -1,6 +1,5 @@
import datetime
import ssl
-import sys
import pytest
import time
from unittest import mock
@@ -29,7 +28,7 @@ def __init__(self, c, user, db, auth=None, authdata=None, password=None):
# already exists - TODO need to check the same plugin applies
self._created = False
try:
- c.execute("GRANT SELECT ON %s.* TO %s" % (db, user))
+ c.execute(f"GRANT SELECT ON {db}.* TO {user}")
self._grant = True
except pymysql.err.InternalError:
self._grant = False
@@ -39,7 +38,7 @@ def __enter__(self):
def __exit__(self, exc_type, exc_value, traceback):
if self._grant:
- self._c.execute("REVOKE SELECT ON %s.* FROM %s" % (self._db, self._user))
+ self._c.execute(f"REVOKE SELECT ON {self._db}.* FROM {self._user}")
if self._created:
self._c.execute("DROP USER %s" % self._user)
@@ -145,8 +144,8 @@ def realtestSocketAuth(self):
TestAuthentication.osuser + "@localhost",
self.databases[0]["database"],
self.socket_plugin_name,
- ) as u:
- c = pymysql.connect(user=TestAuthentication.osuser, **self.db)
+ ):
+ pymysql.connect(user=TestAuthentication.osuser, **self.db)
class Dialog:
fail = False
@@ -168,7 +167,7 @@ def __init__(self, con):
def authenticate(self, pkt):
while True:
flag = pkt.read_uint8()
- echo = (flag & 0x06) == 0x02
+ # echo = (flag & 0x06) == 0x02
last = (flag & 0x01) == 0x01
prompt = pkt.read_all()
@@ -220,7 +219,7 @@ def realTestDialogAuthTwoQuestions(self):
self.databases[0]["database"],
"two_questions",
"notverysecret",
- ) as u:
+ ):
with self.assertRaises(pymysql.err.OperationalError):
pymysql.connect(user="pymysql_2q", **self.db)
pymysql.connect(
@@ -262,7 +261,7 @@ def realTestDialogAuthThreeAttempts(self):
self.databases[0]["database"],
"three_attempts",
"stillnotverysecret",
- ) as u:
+ ):
pymysql.connect(
user="pymysql_3a",
auth_plugin_map={b"dialog": TestAuthentication.Dialog},
@@ -357,9 +356,9 @@ def realTestPamAuth(self):
self.databases[0]["database"],
"pam",
os.environ.get("PAMSERVICE"),
- ) as u:
+ ):
try:
- c = pymysql.connect(user=TestAuthentication.osuser, **db)
+ pymysql.connect(user=TestAuthentication.osuser, **db)
db["password"] = "very bad guess at password"
with self.assertRaises(pymysql.err.OperationalError):
pymysql.connect(
@@ -371,7 +370,8 @@ def realTestPamAuth(self):
)
except pymysql.OperationalError as e:
self.assertEqual(1045, e.args[0])
- # we had 'bad guess at password' work with pam. Well at least we get a permission denied here
+ # we had 'bad guess at password' work with pam. Well at least we get
+ # a permission denied here
with self.assertRaises(pymysql.err.OperationalError):
pymysql.connect(
user=TestAuthentication.osuser,
@@ -397,12 +397,13 @@ def testAuthSHA256(self):
"pymysql_sha256@localhost",
self.databases[0]["database"],
"sha256_password",
- ) as u:
+ ):
c.execute("SET PASSWORD FOR 'pymysql_sha256'@'localhost' ='Sh@256Pa33'")
c.execute("FLUSH PRIVILEGES")
db = self.db.copy()
db["password"] = "Sh@256Pa33"
- # Although SHA256 is supported, need the configuration of public key of the mysql server. Currently will get error by this test.
+ # Although SHA256 is supported, need the configuration of public key of
+ # the mysql server. Currently will get error by this test.
with self.assertRaises(pymysql.err.OperationalError):
pymysql.connect(user="pymysql_sha256", **db)
@@ -423,7 +424,7 @@ def testAuthEd25519(self):
self.databases[0]["database"],
"ed25519",
empty_pass,
- ) as u:
+ ):
pymysql.connect(user="pymysql_ed25519", password="", **db)
with TempUser(
@@ -432,7 +433,7 @@ def testAuthEd25519(self):
self.databases[0]["database"],
"ed25519",
non_empty_pass,
- ) as u:
+ ):
pymysql.connect(user="pymysql_ed25519", password="ed25519_password", **db)
@@ -441,7 +442,21 @@ def test_utf8mb4(self):
"""This test requires MySQL >= 5.5"""
arg = self.databases[0].copy()
arg["charset"] = "utf8mb4"
- conn = pymysql.connect(**arg)
+ pymysql.connect(**arg)
+
+ def test_set_character_set(self):
+ con = self.connect()
+ cur = con.cursor()
+
+ con.set_character_set("latin1")
+ cur.execute("SELECT @@character_set_connection")
+ self.assertEqual(cur.fetchone(), ("latin1",))
+ self.assertEqual(con.encoding, "cp1252")
+
+ con.set_character_set("utf8mb4", "utf8mb4_general_ci")
+ cur.execute("SELECT @@character_set_connection, @@collation_connection")
+ self.assertEqual(cur.fetchone(), ("utf8mb4", "utf8mb4_general_ci"))
+ self.assertEqual(con.encoding, "utf8")
def test_largedata(self):
"""Large query and response (>=16MB)"""
@@ -543,10 +558,8 @@ def test_defer_connect(self):
sock.close()
def test_ssl_connect(self):
- dummy_ssl_context = mock.Mock(options=0)
+ dummy_ssl_context = mock.Mock(options=0, verify_flags=0)
with mock.patch(
- "pymysql.connections.Connection.connect"
- ) as connect, mock.patch(
"pymysql.connections.ssl.create_default_context",
new=mock.Mock(return_value=dummy_ssl_context),
) as create_default_context:
@@ -557,17 +570,20 @@ def test_ssl_connect(self):
"key": "key",
"cipher": "cipher",
},
+ defer_connect=True,
)
assert create_default_context.called
assert dummy_ssl_context.check_hostname
assert dummy_ssl_context.verify_mode == ssl.CERT_REQUIRED
- dummy_ssl_context.load_cert_chain.assert_called_with("cert", keyfile="key")
+ dummy_ssl_context.load_cert_chain.assert_called_with(
+ "cert",
+ keyfile="key",
+ password=None,
+ )
dummy_ssl_context.set_ciphers.assert_called_with("cipher")
- dummy_ssl_context = mock.Mock(options=0)
+ dummy_ssl_context = mock.Mock(options=0, verify_flags=0)
with mock.patch(
- "pymysql.connections.Connection.connect"
- ) as connect, mock.patch(
"pymysql.connections.ssl.create_default_context",
new=mock.Mock(return_value=dummy_ssl_context),
) as create_default_context:
@@ -577,22 +593,50 @@ def test_ssl_connect(self):
"cert": "cert",
"key": "key",
},
+ defer_connect=True,
)
assert create_default_context.called
assert dummy_ssl_context.check_hostname
assert dummy_ssl_context.verify_mode == ssl.CERT_REQUIRED
- dummy_ssl_context.load_cert_chain.assert_called_with("cert", keyfile="key")
+ dummy_ssl_context.load_cert_chain.assert_called_with(
+ "cert",
+ keyfile="key",
+ password=None,
+ )
dummy_ssl_context.set_ciphers.assert_not_called
- dummy_ssl_context = mock.Mock(options=0)
+ dummy_ssl_context = mock.Mock(options=0, verify_flags=0)
+ with mock.patch(
+ "pymysql.connections.ssl.create_default_context",
+ new=mock.Mock(return_value=dummy_ssl_context),
+ ) as create_default_context:
+ pymysql.connect(
+ ssl={
+ "ca": "ca",
+ "cert": "cert",
+ "key": "key",
+ "password": "password",
+ },
+ defer_connect=True,
+ )
+ assert create_default_context.called
+ assert dummy_ssl_context.check_hostname
+ assert dummy_ssl_context.verify_mode == ssl.CERT_REQUIRED
+ dummy_ssl_context.load_cert_chain.assert_called_with(
+ "cert",
+ keyfile="key",
+ password="password",
+ )
+ dummy_ssl_context.set_ciphers.assert_not_called
+
+ dummy_ssl_context = mock.Mock(options=0, verify_flags=0)
with mock.patch(
- "pymysql.connections.Connection.connect"
- ) as connect, mock.patch(
"pymysql.connections.ssl.create_default_context",
new=mock.Mock(return_value=dummy_ssl_context),
) as create_default_context:
pymysql.connect(
ssl_ca="ca",
+ defer_connect=True,
)
assert create_default_context.called
assert not dummy_ssl_context.check_hostname
@@ -600,10 +644,8 @@ def test_ssl_connect(self):
dummy_ssl_context.load_cert_chain.assert_not_called
dummy_ssl_context.set_ciphers.assert_not_called
- dummy_ssl_context = mock.Mock(options=0)
+ dummy_ssl_context = mock.Mock(options=0, verify_flags=0)
with mock.patch(
- "pymysql.connections.Connection.connect"
- ) as connect, mock.patch(
"pymysql.connections.ssl.create_default_context",
new=mock.Mock(return_value=dummy_ssl_context),
) as create_default_context:
@@ -611,18 +653,21 @@ def test_ssl_connect(self):
ssl_ca="ca",
ssl_cert="cert",
ssl_key="key",
+ defer_connect=True,
)
assert create_default_context.called
assert not dummy_ssl_context.check_hostname
assert dummy_ssl_context.verify_mode == ssl.CERT_NONE
- dummy_ssl_context.load_cert_chain.assert_called_with("cert", keyfile="key")
+ dummy_ssl_context.load_cert_chain.assert_called_with(
+ "cert",
+ keyfile="key",
+ password=None,
+ )
dummy_ssl_context.set_ciphers.assert_not_called
for ssl_verify_cert in (True, "1", "yes", "true"):
- dummy_ssl_context = mock.Mock(options=0)
+ dummy_ssl_context = mock.Mock(options=0, verify_flags=0)
with mock.patch(
- "pymysql.connections.Connection.connect"
- ) as connect, mock.patch(
"pymysql.connections.ssl.create_default_context",
new=mock.Mock(return_value=dummy_ssl_context),
) as create_default_context:
@@ -630,20 +675,21 @@ def test_ssl_connect(self):
ssl_cert="cert",
ssl_key="key",
ssl_verify_cert=ssl_verify_cert,
+ defer_connect=True,
)
assert create_default_context.called
assert not dummy_ssl_context.check_hostname
assert dummy_ssl_context.verify_mode == ssl.CERT_REQUIRED
dummy_ssl_context.load_cert_chain.assert_called_with(
- "cert", keyfile="key"
+ "cert",
+ keyfile="key",
+ password=None,
)
dummy_ssl_context.set_ciphers.assert_not_called
for ssl_verify_cert in (None, False, "0", "no", "false"):
- dummy_ssl_context = mock.Mock(options=0)
+ dummy_ssl_context = mock.Mock(options=0, verify_flags=0)
with mock.patch(
- "pymysql.connections.Connection.connect"
- ) as connect, mock.patch(
"pymysql.connections.ssl.create_default_context",
new=mock.Mock(return_value=dummy_ssl_context),
) as create_default_context:
@@ -651,21 +697,22 @@ def test_ssl_connect(self):
ssl_cert="cert",
ssl_key="key",
ssl_verify_cert=ssl_verify_cert,
+ defer_connect=True,
)
assert create_default_context.called
assert not dummy_ssl_context.check_hostname
assert dummy_ssl_context.verify_mode == ssl.CERT_NONE
dummy_ssl_context.load_cert_chain.assert_called_with(
- "cert", keyfile="key"
+ "cert",
+ keyfile="key",
+ password=None,
)
dummy_ssl_context.set_ciphers.assert_not_called
for ssl_ca in ("ca", None):
for ssl_verify_cert in ("foo", "bar", ""):
- dummy_ssl_context = mock.Mock(options=0)
+ dummy_ssl_context = mock.Mock(options=0, verify_flags=0)
with mock.patch(
- "pymysql.connections.Connection.connect"
- ) as connect, mock.patch(
"pymysql.connections.ssl.create_default_context",
new=mock.Mock(return_value=dummy_ssl_context),
) as create_default_context:
@@ -674,6 +721,7 @@ def test_ssl_connect(self):
ssl_cert="cert",
ssl_key="key",
ssl_verify_cert=ssl_verify_cert,
+ defer_connect=True,
)
assert create_default_context.called
assert not dummy_ssl_context.check_hostname
@@ -681,14 +729,14 @@ def test_ssl_connect(self):
ssl.CERT_REQUIRED if ssl_ca is not None else ssl.CERT_NONE
), (ssl_ca, ssl_verify_cert)
dummy_ssl_context.load_cert_chain.assert_called_with(
- "cert", keyfile="key"
+ "cert",
+ keyfile="key",
+ password=None,
)
dummy_ssl_context.set_ciphers.assert_not_called
- dummy_ssl_context = mock.Mock(options=0)
+ dummy_ssl_context = mock.Mock(options=0, verify_flags=0)
with mock.patch(
- "pymysql.connections.Connection.connect"
- ) as connect, mock.patch(
"pymysql.connections.ssl.create_default_context",
new=mock.Mock(return_value=dummy_ssl_context),
) as create_default_context:
@@ -697,17 +745,43 @@ def test_ssl_connect(self):
ssl_cert="cert",
ssl_key="key",
ssl_verify_identity=True,
+ defer_connect=True,
)
assert create_default_context.called
assert dummy_ssl_context.check_hostname
assert dummy_ssl_context.verify_mode == ssl.CERT_NONE
- dummy_ssl_context.load_cert_chain.assert_called_with("cert", keyfile="key")
+ dummy_ssl_context.load_cert_chain.assert_called_with(
+ "cert",
+ keyfile="key",
+ password=None,
+ )
dummy_ssl_context.set_ciphers.assert_not_called
- dummy_ssl_context = mock.Mock(options=0)
+ dummy_ssl_context = mock.Mock(options=0, verify_flags=0)
+ with mock.patch(
+ "pymysql.connections.ssl.create_default_context",
+ new=mock.Mock(return_value=dummy_ssl_context),
+ ) as create_default_context:
+ pymysql.connect(
+ ssl_ca="ca",
+ ssl_cert="cert",
+ ssl_key="key",
+ ssl_key_password="password",
+ ssl_verify_identity=True,
+ defer_connect=True,
+ )
+ assert create_default_context.called
+ assert dummy_ssl_context.check_hostname
+ assert dummy_ssl_context.verify_mode == ssl.CERT_NONE
+ dummy_ssl_context.load_cert_chain.assert_called_with(
+ "cert",
+ keyfile="key",
+ password="password",
+ )
+ dummy_ssl_context.set_ciphers.assert_not_called
+
+ dummy_ssl_context = mock.Mock(options=0, verify_flags=0)
with mock.patch(
- "pymysql.connections.Connection.connect"
- ) as connect, mock.patch(
"pymysql.connections.ssl.create_default_context",
new=mock.Mock(return_value=dummy_ssl_context),
) as create_default_context:
@@ -718,13 +792,12 @@ def test_ssl_connect(self):
"cert": "cert",
"key": "key",
},
+ defer_connect=True,
)
assert not create_default_context.called
- dummy_ssl_context = mock.Mock(options=0)
+ dummy_ssl_context = mock.Mock(options=0, verify_flags=0)
with mock.patch(
- "pymysql.connections.Connection.connect"
- ) as connect, mock.patch(
"pymysql.connections.ssl.create_default_context",
new=mock.Mock(return_value=dummy_ssl_context),
) as create_default_context:
@@ -733,6 +806,7 @@ def test_ssl_connect(self):
ssl_ca="ca",
ssl_cert="cert",
ssl_key="key",
+ defer_connect=True,
)
assert not create_default_context.called
@@ -762,21 +836,18 @@ def test_escape_string(self):
def test_escape_builtin_encoders(self):
con = self.connect()
- cur = con.cursor()
val = datetime.datetime(2012, 3, 4, 5, 6)
self.assertEqual(con.escape(val, con.encoders), "'2012-03-04 05:06:00'")
def test_escape_custom_object(self):
con = self.connect()
- cur = con.cursor()
mapping = {Foo: escape_foo}
self.assertEqual(con.escape(Foo(), mapping), "bar")
def test_escape_fallback_encoder(self):
con = self.connect()
- cur = con.cursor()
class Custom(str):
pass
@@ -786,21 +857,21 @@ class Custom(str):
def test_escape_no_default(self):
con = self.connect()
- cur = con.cursor()
self.assertRaises(TypeError, con.escape, 42, {})
- def test_escape_dict_value(self):
+ def test_escape_dict_raise_typeerror(self):
+ """con.escape(dict) should raise TypeError"""
con = self.connect()
- cur = con.cursor()
mapping = con.encoders.copy()
mapping[Foo] = escape_foo
- self.assertEqual(con.escape({"foo": Foo()}, mapping), {"foo": "bar"})
+ # self.assertEqual(con.escape({"foo": Foo()}, mapping), {"foo": "bar"})
+ with self.assertRaises(TypeError):
+ con.escape({"foo": Foo()})
def test_escape_list_item(self):
con = self.connect()
- cur = con.cursor()
mapping = con.encoders.copy()
mapping[Foo] = escape_foo
@@ -824,3 +895,40 @@ def test_commit_during_multi_result(self):
con.commit()
cur.execute("SELECT 3")
self.assertEqual(cur.fetchone()[0], 3)
+
+ def test_force_close_closes_socketio(self):
+ con = self.connect()
+ sock = con._sock
+ fileno = sock.fileno()
+ rfile = con._rfile
+
+ con._force_close()
+ assert rfile.closed
+ assert sock._closed
+ assert sock.fileno() != fileno # should be set to -1
+
+ def test_socket_closed_on_exception_in_connect(self):
+ con = self.connect(defer_connect=True)
+ sock = None
+ rfile = None
+ fileno = -1
+
+ def _request_authentication():
+ nonlocal sock, rfile, fileno
+ sock = con._sock
+ assert sock is not None
+ fileno = sock.fileno()
+ rfile = con._rfile
+ assert rfile is not None
+ raise TypeError
+
+ con._request_authentication = _request_authentication
+
+ with pytest.raises(TypeError):
+ con.connect()
+ assert not con.open
+ assert con._rfile is None
+ assert con._sock is None
+ assert rfile.closed
+ assert sock._closed
+ assert sock.fileno() != fileno # should be set to -1
diff --git a/pymysql/tests/test_cursor.py b/pymysql/tests/test_cursor.py
index 783caf88b..2e267fb6a 100644
--- a/pymysql/tests/test_cursor.py
+++ b/pymysql/tests/test_cursor.py
@@ -1,12 +1,13 @@
-import warnings
-
+from pymysql.constants import ER
from pymysql.tests import base
import pymysql.cursors
+import pytest
+
class CursorTest(base.PyMySQLTestCase):
def setUp(self):
- super(CursorTest, self).setUp()
+ super().setUp()
conn = self.connect()
self.safe_create_table(
@@ -16,13 +17,21 @@ def setUp(self):
)
cursor = conn.cursor()
cursor.execute(
- "insert into test (data) values "
- "('row1'), ('row2'), ('row3'), ('row4'), ('row5')"
+ "insert into test (data) values ('row1'), ('row2'), ('row3'), ('row4'), ('row5')"
)
+ conn.commit()
cursor.close()
self.test_connection = pymysql.connect(**self.databases[0])
self.addCleanup(self.test_connection.close)
+ def test_cursor_is_iterator(self):
+ """Test that the cursor is an iterator"""
+ conn = self.test_connection
+ cursor = conn.cursor()
+ cursor.execute("select * from test")
+ self.assertEqual(cursor.__iter__(), cursor)
+ self.assertEqual(cursor.__next__(), ("row1",))
+
def test_cleanup_rows_unbuffered(self):
conn = self.test_connection
cursor = conn.cursor(pymysql.cursors.SSCursor)
@@ -95,7 +104,8 @@ def test_executemany(self):
)
assert m is not None
- # cursor._executed must bee "insert into test (data) values (0),(1),(2),(3),(4),(5),(6),(7),(8),(9)"
+ # cursor._executed must bee "insert into test (data)
+ # values (0),(1),(2),(3),(4),(5),(6),(7),(8),(9)"
# list args
data = range(10)
cursor.executemany("insert into test (data) values (%s)", data)
@@ -129,3 +139,84 @@ def test_executemany(self):
)
finally:
cursor.execute("DROP TABLE IF EXISTS percent_test")
+
+ def test_execution_time_limit(self):
+ # this method is similarly implemented in test_SScursor
+
+ conn = self.test_connection
+ db_type = self.get_mysql_vendor(conn)
+
+ with conn.cursor(pymysql.cursors.Cursor) as cur:
+ # MySQL MAX_EXECUTION_TIME takes ms
+ # MariaDB max_statement_time takes seconds as int/float, introduced in 10.1
+
+ # this will sleep 0.01 seconds per row
+ if db_type == "mysql":
+ sql = (
+ "SELECT /*+ MAX_EXECUTION_TIME(2000) */ data, sleep(0.01) FROM test"
+ )
+ else:
+ sql = "SET STATEMENT max_statement_time=2 FOR SELECT data, sleep(0.01) FROM test"
+
+ cur.execute(sql)
+ # unlike SSCursor, Cursor returns a tuple of tuples here
+ self.assertEqual(
+ cur.fetchall(),
+ (
+ ("row1", 0),
+ ("row2", 0),
+ ("row3", 0),
+ ("row4", 0),
+ ("row5", 0),
+ ),
+ )
+
+ if db_type == "mysql":
+ sql = (
+ "SELECT /*+ MAX_EXECUTION_TIME(2000) */ data, sleep(0.01) FROM test"
+ )
+ else:
+ sql = "SET STATEMENT max_statement_time=2 FOR SELECT data, sleep(0.01) FROM test"
+ cur.execute(sql)
+ self.assertEqual(cur.fetchone(), ("row1", 0))
+
+ # this discards the previous unfinished query
+ cur.execute("SELECT 1")
+ self.assertEqual(cur.fetchone(), (1,))
+
+ if db_type == "mysql":
+ sql = "SELECT /*+ MAX_EXECUTION_TIME(1) */ data, sleep(1) FROM test"
+ else:
+ sql = "SET STATEMENT max_statement_time=0.001 FOR SELECT data, sleep(1) FROM test"
+ with pytest.raises(pymysql.err.OperationalError) as cm:
+ # in a buffered cursor this should reliably raise an
+ # OperationalError
+ cur.execute(sql)
+
+ if db_type == "mysql":
+ # this constant was only introduced in MySQL 5.7, not sure
+ # what was returned before, may have been ER_QUERY_INTERRUPTED
+ self.assertEqual(cm.value.args[0], ER.QUERY_TIMEOUT)
+ else:
+ self.assertEqual(cm.value.args[0], ER.STATEMENT_TIMEOUT)
+
+ # connection should still be fine at this point
+ cur.execute("SELECT 1")
+ self.assertEqual(cur.fetchone(), (1,))
+
+ def test_warnings(self):
+ con = self.connect()
+ cur = con.cursor()
+ cur.execute("DROP TABLE IF EXISTS `no_exists_table`")
+ self.assertEqual(cur.warning_count, 1)
+
+ cur.execute("SHOW WARNINGS")
+ w = cur.fetchone()
+ self.assertEqual(w[1], ER.BAD_TABLE_ERROR)
+ self.assertIn(
+ "no_exists_table",
+ w[2],
+ )
+
+ cur.execute("SELECT 1")
+ self.assertEqual(cur.warning_count, 0)
diff --git a/pymysql/tests/test_err.py b/pymysql/tests/test_err.py
index 6b54c6d04..6eb0f987d 100644
--- a/pymysql/tests/test_err.py
+++ b/pymysql/tests/test_err.py
@@ -1,14 +1,16 @@
-import unittest
-
+import pytest
from pymysql import err
-__all__ = ["TestRaiseException"]
-
+def test_raise_mysql_exception():
+ data = b"\xff\x15\x04#28000Access denied"
+ with pytest.raises(err.OperationalError) as cm:
+ err.raise_mysql_exception(data)
+ assert cm.type == err.OperationalError
+ assert cm.value.args == (1045, "Access denied")
-class TestRaiseException(unittest.TestCase):
- def test_raise_mysql_exception(self):
- data = b"\xff\x15\x04#28000Access denied"
- with self.assertRaises(err.OperationalError) as cm:
- err.raise_mysql_exception(data)
- self.assertEqual(cm.exception.args, (1045, "Access denied"))
+ data = b"\xff\x10\x04Too many connections"
+ with pytest.raises(err.OperationalError) as cm:
+ err.raise_mysql_exception(data)
+ assert cm.type == err.OperationalError
+ assert cm.value.args == (1040, "Too many connections")
diff --git a/pymysql/tests/test_issues.py b/pymysql/tests/test_issues.py
index 733d56a16..f1fe8dd48 100644
--- a/pymysql/tests/test_issues.py
+++ b/pymysql/tests/test_issues.py
@@ -1,12 +1,10 @@
import datetime
import time
import warnings
-import sys
import pytest
import pymysql
-from pymysql import cursors
from pymysql.tests import base
__all__ = ["TestOldIssues", "TestNewIssues", "TestGitHubIssues"]
@@ -381,8 +379,8 @@ def test_issue_175(self):
conn = self.connect()
cur = conn.cursor()
for length in (200, 300):
- columns = ", ".join("c{0} integer".format(i) for i in range(length))
- sql = "create table test_field_count ({0})".format(columns)
+ columns = ", ".join(f"c{i} integer" for i in range(length))
+ sql = f"create table test_field_count ({columns})"
try:
cur.execute(sql)
cur.execute("select * from test_field_count")
@@ -403,10 +401,9 @@ def test_issue_321(self):
sql_insert = "insert into issue321 (value_1, value_2) values (%s, %s)"
sql_dict_insert = (
- "insert into issue321 (value_1, value_2) "
- "values (%(value_1)s, %(value_2)s)"
+ "insert into issue321 (value_1, value_2) values (%(value_1)s, %(value_2)s)"
)
- sql_select = "select * from issue321 where " "value_1 in %s and value_2=%s"
+ sql_select = "select * from issue321 where value_1 in %s and value_2=%s"
data = [
[("a",), "\u0430"],
[["b"], "\u0430"],
diff --git a/pymysql/tests/test_load_local.py b/pymysql/tests/test_load_local.py
index b1b8128e4..509221420 100644
--- a/pymysql/tests/test_load_local.py
+++ b/pymysql/tests/test_load_local.py
@@ -1,4 +1,5 @@
-from pymysql import cursors, OperationalError, Warning
+from pymysql import cursors, OperationalError
+from pymysql.constants import ER
from pymysql.tests import base
import os
@@ -35,7 +36,8 @@ def test_load_file(self):
)
try:
c.execute(
- f"LOAD DATA LOCAL INFILE '{filename}' INTO TABLE test_load_local FIELDS TERMINATED BY ','"
+ f"LOAD DATA LOCAL INFILE '{filename}' INTO TABLE test_load_local"
+ + " FIELDS TERMINATED BY ','"
)
c.execute("SELECT COUNT(*) FROM test_load_local")
self.assertEqual(22749, c.fetchone()[0])
@@ -52,7 +54,8 @@ def test_unbuffered_load_file(self):
)
try:
c.execute(
- f"LOAD DATA LOCAL INFILE '{filename}' INTO TABLE test_load_local FIELDS TERMINATED BY ','"
+ f"LOAD DATA LOCAL INFILE '{filename}' INTO TABLE test_load_local"
+ + " FIELDS TERMINATED BY ','"
)
c.execute("SELECT COUNT(*) FROM test_load_local")
self.assertEqual(22749, c.fetchone()[0])
@@ -63,6 +66,37 @@ def test_unbuffered_load_file(self):
c = conn.cursor()
c.execute("DROP TABLE test_load_local")
+ def test_load_warnings(self):
+ """Test load local infile produces the appropriate warnings"""
+ conn = self.connect()
+ c = conn.cursor()
+ c.execute("CREATE TABLE test_load_local (a INTEGER, b INTEGER)")
+ filename = os.path.join(
+ os.path.dirname(os.path.realpath(__file__)),
+ "data",
+ "load_local_warn_data.txt",
+ )
+ try:
+ c.execute(
+ (
+ "LOAD DATA LOCAL INFILE '{0}' INTO TABLE "
+ + "test_load_local FIELDS TERMINATED BY ','"
+ ).format(filename)
+ )
+ self.assertEqual(1, c.warning_count)
+
+ c.execute("SHOW WARNINGS")
+ w = c.fetchone()
+
+ self.assertEqual(ER.TRUNCATED_WRONG_VALUE_FOR_FIELD, w[1])
+ self.assertIn(
+ "incorrect integer value",
+ w[2].lower(),
+ )
+ finally:
+ c.execute("DROP TABLE test_load_local")
+ c.close()
+
if __name__ == "__main__":
import unittest
diff --git a/pymysql/tests/test_nextset.py b/pymysql/tests/test_nextset.py
index 28972325d..a10f8d5b7 100644
--- a/pymysql/tests/test_nextset.py
+++ b/pymysql/tests/test_nextset.py
@@ -38,7 +38,7 @@ def test_nextset_error(self):
self.assertEqual([(i,)], list(cur))
with self.assertRaises(pymysql.ProgrammingError):
cur.nextset()
- self.assertEqual((), cur.fetchall())
+ self.assertEqual([], cur.fetchall())
def test_ok_and_next(self):
cur = self.connect(client_flag=CLIENT.MULTI_STATEMENTS).cursor()
@@ -75,7 +75,7 @@ def test_multi_statement_warnings(self):
cursor = con.cursor()
try:
- cursor.execute("DROP TABLE IF EXISTS a; " "DROP TABLE IF EXISTS b;")
+ cursor.execute("DROP TABLE IF EXISTS a; DROP TABLE IF EXISTS b;")
except TypeError:
self.fail()
diff --git a/pymysql/tests/test_optionfile.py b/pymysql/tests/test_optionfile.py
index 39bd47c46..d13553dda 100644
--- a/pymysql/tests/test_optionfile.py
+++ b/pymysql/tests/test_optionfile.py
@@ -21,4 +21,4 @@ def test_string(self):
parser.read_file(StringIO(_cfg_file))
self.assertEqual(parser.get("default", "string"), "foo")
self.assertEqual(parser.get("default", "quoted"), "bar")
- self.assertEqual(parser.get("default", "single_quoted"), "foobar")
+ self.assertEqual(parser.get("default", "single-quoted"), "foobar")
diff --git a/pymysql/tests/thirdparty/test_MySQLdb/__init__.py b/pymysql/tests/thirdparty/test_MySQLdb/__init__.py
index 57c42ce7a..501bfd2db 100644
--- a/pymysql/tests/thirdparty/test_MySQLdb/__init__.py
+++ b/pymysql/tests/thirdparty/test_MySQLdb/__init__.py
@@ -1,6 +1,4 @@
-from .test_MySQLdb_capabilities import test_MySQLdb as test_capabilities
from .test_MySQLdb_nonstandard import *
-from .test_MySQLdb_dbapi20 import test_MySQLdb as test_dbapi2
if __name__ == "__main__":
import unittest
diff --git a/pymysql/tests/thirdparty/test_MySQLdb/capabilities.py b/pymysql/tests/thirdparty/test_MySQLdb/capabilities.py
index 0276a558a..bb47cc5f6 100644
--- a/pymysql/tests/thirdparty/test_MySQLdb/capabilities.py
+++ b/pymysql/tests/thirdparty/test_MySQLdb/capabilities.py
@@ -4,7 +4,6 @@
Adapted from a script by M-A Lemburg.
"""
-import sys
from time import time
import unittest
diff --git a/pymysql/tests/thirdparty/test_MySQLdb/dbapi20.py b/pymysql/tests/thirdparty/test_MySQLdb/dbapi20.py
index 30620ce41..fff14b86f 100644
--- a/pymysql/tests/thirdparty/test_MySQLdb/dbapi20.py
+++ b/pymysql/tests/thirdparty/test_MySQLdb/dbapi20.py
@@ -225,7 +225,7 @@ def test_rollback(self):
def test_cursor(self):
con = self._connect()
try:
- cur = con.cursor()
+ con.cursor()
finally:
con.close()
@@ -299,7 +299,7 @@ def test_rowcount(self):
self.assertEqual(
cur.rowcount,
-1,
- "cursor.rowcount should be -1 after executing no-result " "statements",
+ "cursor.rowcount should be -1 after executing no-result statements",
)
cur.execute(
"insert into %sbooze values ('Victoria Bitter')" % (self.table_prefix)
@@ -409,12 +409,12 @@ def _paraminsert(self, cur):
self.assertEqual(
beers[0],
"Cooper's",
- "cursor.fetchall retrieved incorrect data, or data inserted " "incorrectly",
+ "cursor.fetchall retrieved incorrect data, or data inserted incorrectly",
)
self.assertEqual(
beers[1],
"Victoria Bitter",
- "cursor.fetchall retrieved incorrect data, or data inserted " "incorrectly",
+ "cursor.fetchall retrieved incorrect data, or data inserted incorrectly",
)
def test_executemany(self):
@@ -482,7 +482,7 @@ def test_fetchone(self):
self.assertEqual(
cur.fetchone(),
None,
- "cursor.fetchone should return None if a query retrieves " "no rows",
+ "cursor.fetchone should return None if a query retrieves no rows",
)
self.assertTrue(cur.rowcount in (-1, 0))
@@ -810,28 +810,26 @@ def test_None(self):
con.close()
def test_Date(self):
- d1 = self.driver.Date(2002, 12, 25)
- d2 = self.driver.DateFromTicks(time.mktime((2002, 12, 25, 0, 0, 0, 0, 0, 0)))
+ self.driver.Date(2002, 12, 25)
+ self.driver.DateFromTicks(time.mktime((2002, 12, 25, 0, 0, 0, 0, 0, 0)))
# Can we assume this? API doesn't specify, but it seems implied
# self.assertEqual(str(d1),str(d2))
def test_Time(self):
- t1 = self.driver.Time(13, 45, 30)
- t2 = self.driver.TimeFromTicks(time.mktime((2001, 1, 1, 13, 45, 30, 0, 0, 0)))
+ self.driver.Time(13, 45, 30)
+ self.driver.TimeFromTicks(time.mktime((2001, 1, 1, 13, 45, 30, 0, 0, 0)))
# Can we assume this? API doesn't specify, but it seems implied
# self.assertEqual(str(t1),str(t2))
def test_Timestamp(self):
- t1 = self.driver.Timestamp(2002, 12, 25, 13, 45, 30)
- t2 = self.driver.TimestampFromTicks(
- time.mktime((2002, 12, 25, 13, 45, 30, 0, 0, 0))
- )
+ self.driver.Timestamp(2002, 12, 25, 13, 45, 30)
+ self.driver.TimestampFromTicks(time.mktime((2002, 12, 25, 13, 45, 30, 0, 0, 0)))
# Can we assume this? API doesn't specify, but it seems implied
# self.assertEqual(str(t1),str(t2))
def test_Binary(self):
- b = self.driver.Binary(b"Something")
- b = self.driver.Binary(b"")
+ self.driver.Binary(b"Something")
+ self.driver.Binary(b"")
def test_STRING(self):
self.assertTrue(hasattr(self.driver, "STRING"), "module.STRING must be defined")
diff --git a/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_capabilities.py b/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_capabilities.py
index 11bfdbe29..6a2894a5a 100644
--- a/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_capabilities.py
+++ b/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_capabilities.py
@@ -1,5 +1,4 @@
from . import capabilities
-import unittest
import pymysql
from pymysql.tests import base
import warnings
diff --git a/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py b/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py
index bc1e1b2ea..5c34d40d1 100644
--- a/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py
+++ b/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py
@@ -2,8 +2,6 @@
import pymysql
from pymysql.tests import base
-import unittest
-
class test_MySQLdb(dbapi20.DatabaseAPI20Test):
driver = pymysql
@@ -100,7 +98,7 @@ def test_fetchone(self):
self.assertEqual(
cur.fetchone(),
None,
- "cursor.fetchone should return None if a query retrieves " "no rows",
+ "cursor.fetchone should return None if a query retrieves no rows",
)
self.assertTrue(cur.rowcount in (-1, 0))
@@ -181,8 +179,6 @@ def help_nextset_tearDown(self, cur):
cur.execute("drop procedure deleteme")
def test_nextset(self):
- from warnings import warn
-
con = self._connect()
try:
cur = con.cursor()
diff --git a/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_nonstandard.py b/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_nonstandard.py
index b8d4bb1e6..1545fbb5e 100644
--- a/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_nonstandard.py
+++ b/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_nonstandard.py
@@ -1,4 +1,3 @@
-import sys
import unittest
import pymysql
diff --git a/pyproject.toml b/pyproject.toml
index 0f043181a..ee103916f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,8 @@ classifiers = [
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Intended Audience :: Developers",
@@ -40,7 +42,7 @@ dynamic = ["version"]
"Documentation" = "https://pymysql.readthedocs.io/"
[build-system]
-requires = ["setuptools>=61", "wheel"]
+requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
@@ -49,4 +51,17 @@ include = ["pymysql*"]
exclude = ["tests*", "pymysql.tests*"]
[tool.setuptools.dynamic]
-version = {attr = "pymysql.VERSION"}
+version = {attr = "pymysql.VERSION_STRING"}
+
+[tool.ruff]
+exclude = [
+ "pymysql/tests/thirdparty",
+]
+
+[tool.ruff.lint]
+ignore = ["E721"]
+
+[tool.pdm.dev-dependencies]
+dev = [
+ "pytest-cov>=4.0.0",
+]
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 000000000..09e16da6b
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": [
+ "config:base"
+ ],
+ "dependencyDashboard": false
+}
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 13d7f7fb4..140d37067 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -2,4 +2,3 @@ cryptography
PyNaCl>=1.4.0
pytest
pytest-cov
-coveralls
diff --git a/tests/test_auth.py b/tests/test_auth.py
index e5e2a64e5..d7a0e82fc 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -71,6 +71,19 @@ def test_caching_sha2_password():
con.query("FLUSH PRIVILEGES")
con.close()
+ # Fast path after auth_switch_request
+ pymysql.connections._DEFAULT_AUTH_PLUGIN = "mysql_native_password"
+ con = pymysql.connect(
+ user="user_caching_sha2",
+ password=pass_caching_sha2,
+ host=host,
+ port=port,
+ ssl=ssl,
+ )
+ con.query("FLUSH PRIVILEGES")
+ con.close()
+ pymysql.connections._DEFAULT_AUTH_PLUGIN = None
+
def test_caching_sha2_password_ssl():
con = pymysql.connect(
@@ -88,7 +101,20 @@ def test_caching_sha2_password_ssl():
password=pass_caching_sha2,
host=host,
port=port,
- ssl=None,
+ ssl=ssl,
+ )
+ con.query("FLUSH PRIVILEGES")
+ con.close()
+
+ # Fast path after auth_switch_request
+ pymysql.connections._DEFAULT_AUTH_PLUGIN = "mysql_native_password"
+ con = pymysql.connect(
+ user="user_caching_sha2",
+ password=pass_caching_sha2,
+ host=host,
+ port=port,
+ ssl=ssl,
)
con.query("FLUSH PRIVILEGES")
con.close()
+ pymysql.connections._DEFAULT_AUTH_PLUGIN = None