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/.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 b6a7238dd..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: [ master ]
-  pull_request:
-    # The branches below must be a subset of the branches above
-    branches: [ master ]
-  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@v1
-      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@v1
-
-    # â„šī¸ 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@v1
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 887a8f261..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:
@@ -10,16 +11,14 @@ on:
 
 jobs:
   lint:
-    runs-on: ubuntu-20.04
+    runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-python@v2
-      - 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/test.yaml b/.github/workflows/test.yaml
index 09846c943..6abc96b70 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -4,31 +4,44 @@ on:
   push:
   pull_request:
 
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
+  cancel-in-progress: true
+
+env:
+  FORCE_COLOR: 1
+
 jobs:
   test:
-    runs-on: ubuntu-20.04
+    runs-on: ubuntu-latest
     strategy:
+      fail-fast: false
       matrix:
         include:
-          - db: "mariadb:10.0"
-            py: "3.9"
-
-          - db: "mariadb:10.3"
-            py: "3.8"
-            mariadb_auth: true
+          - db: "mariadb:10.4"
+            py: "3.13"
 
           - db: "mariadb:10.5"
-            py: "3.7"
-            mariadb_auth: true
+            py: "3.11"
 
-          - db: "mysql:5.6"
-            py: "3.6"
+          - 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.6"
+            py: "pypy-3.10"
 
           - db: "mysql:8.0"
-            py: "3.9"
+            py: "3.13"
+            mysql_auth: true
+
+          - db: "mysql:8.4"
+            py: "3.8"
             mysql_auth: true
 
     services:
@@ -38,25 +51,31 @@ 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@v2
+      - uses: actions/checkout@v4
+
+      - name: Workaround MySQL container permissions
+        if: startsWith(matrix.db, 'mysql')
+        run: |
+          sudo chown 999:999 /run/mysqld
+          /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@v2
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.py }}
-
-      - uses: actions/cache@v2
-        with:
-          path: ~/.cache/pip
-          key: ${{ runner.os }}-pip-1
-          restore-keys: |
-            ${{ runner.os }}-pip-
+          allow-prereleases: true
+          cache: 'pip'
+          cache-dependency-path: 'requirements-dev.txt'
 
       - name: Install dependency
         run: |
-          pip install -U cryptography PyNaCl pytest pytest-cov coveralls
+          pip install --upgrade -r requirements-dev.txt
 
       - name: Set up MySQL
         run: |
@@ -66,15 +85,15 @@ jobs:
               mysql -h127.0.0.1 -uroot -e 'select version()' && break
           done
           mysql -h127.0.0.1 -uroot -e "SET GLOBAL local_infile=on"
-          mysql -h127.0.0.1 -uroot -e 'create database test1 DEFAULT CHARACTER SET utf8mb4'
-          mysql -h127.0.0.1 -uroot -e 'create database test2 DEFAULT CHARACTER SET utf8mb4'
-          mysql -h127.0.0.1 -uroot -e "create user test2           identified ${WITH_PLUGIN} by 'some password'; grant all on test2.* to test2;"
-          mysql -h127.0.0.1 -uroot -e "create user test2@localhost identified ${WITH_PLUGIN} by 'some password'; grant all on test2.* to test2@localhost;"
+          mysql -h127.0.0.1 -uroot --comments < ci/docker-entrypoint-initdb.d/init.sql
+          mysql -h127.0.0.1 -uroot --comments < ci/docker-entrypoint-initdb.d/mysql.sql
+          mysql -h127.0.0.1 -uroot --comments < ci/docker-entrypoint-initdb.d/mariadb.sql
           cp ci/docker.json pymysql/tests/databases.json
 
       - name: Run test
         run: |
           pytest -v --cov --cov-config .coveragerc pymysql
+          pytest -v --cov-append --cov-config .coveragerc --doctest-modules pymysql/converters.py
 
       - name: Run MySQL8 auth test
         if: ${{ matrix.mysql_auth }}
@@ -84,43 +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}"
-          mysql -uroot -h127.0.0.1 -e '
-              CREATE USER
-                  user_sha256   IDENTIFIED WITH "sha256_password" BY "pass_sha256_01234567890123456789",
-                  nopass_sha256 IDENTIFIED WITH "sha256_password",
-                  user_caching_sha2   IDENTIFIED WITH "caching_sha2_password" BY "pass_caching_sha2_01234567890123456789",
-                  nopass_caching_sha2 IDENTIFIED WITH "caching_sha2_password"
-                  PASSWORD EXPIRE NEVER;
-              GRANT RELOAD ON *.* TO user_caching_sha2;'
-          pytest -v --cov --cov-config .coveragerc tests/test_auth.py;
-
-      - name: Run MariaDB auth test
-        if: ${{ matrix.mariadb_auth }}
-        run: |
-          mysql -uroot -h127.0.0.1 -e '
-              INSTALL SONAME "auth_ed25519";
-              CREATE FUNCTION ed25519_password RETURNS STRING SONAME "auth_ed25519.so";'
-          # we need to pass the hashed password manually until 10.4, so hide it here
-          mysql -uroot -h127.0.0.1 -sNe "SELECT CONCAT('CREATE USER nopass_ed25519 IDENTIFIED VIA ed25519 USING \"',ed25519_password(\"\"),'\";');" | mysql -uroot -h127.0.0.1
-          mysql -uroot -h127.0.0.1 -sNe "SELECT CONCAT('CREATE USER user_ed25519 IDENTIFIED VIA ed25519 USING \"',ed25519_password(\"pass_ed25519\"),'\";');" | mysql -uroot -h127.0.0.1
-          pytest -v --cov --cov-config .coveragerc tests/test_mariadb_auth.py
-
-      - name: Report coverage
-        run: coveralls
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          COVERALLS_FLAG_NAME: ${{ matrix.test-name }}
-          COVERALLS_PARALLEL: true
-
-  coveralls:
-    name: Finish coveralls
-    runs-on: ubuntu-20.04
-    needs: test
-    container: python:3-slim
-    steps:
-    - name: Finished
-      run: |
-        pip3 install --upgrade coveralls
-        coveralls --finish
-      env:
-        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          pytest -v --cov-append --cov-config .coveragerc tests/test_auth.py;
+
+      - name: Upload coverage reports to Codecov
+        if: github.repository == 'PyMySQL/PyMySQL'
+        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 9885af526..a633f6c51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,60 @@
 # 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: 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
 
 Release date: 2021-01-09
@@ -195,7 +250,7 @@ Release date: 2016-08-30
 Release date: 2016-07-29
 
 * Fix SELECT JSON type cause UnicodeError
-* Avoid float convertion while parsing microseconds
+* Avoid float conversion while parsing microseconds
 * Warning has number
 * SSCursor supports warnings
 
diff --git a/MANIFEST.in b/MANIFEST.in
index e9e1eebcb..e2e577a9d 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1 +1 @@
-include README.rst LICENSE CHANGELOG.md
+include README.md LICENSE CHANGELOG.md
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..95e4520a9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,106 @@
+[![Documentation Status](https://readthedocs.org/projects/pymysql/badge/?version=latest)](https://pymysql.readthedocs.io/)
+[![codecov](https://codecov.io/gh/PyMySQL/PyMySQL/branch/main/graph/badge.svg?token=ppEuaNXBW4)](https://codecov.io/gh/PyMySQL/PyMySQL)
+
+# PyMySQL
+
+This package contains a pure-Python MySQL and MariaDB client library, based on
+[PEP 249](https://www.python.org/dev/peps/pep-0249/).
+
+## Requirements
+
+- Python -- one of the following:
+  - [CPython](https://www.python.org/) : 3.7 and newer
+  - [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.4
+
+## Installation
+
+Package is uploaded on [PyPI](https://pypi.org/project/PyMySQL).
+
+You can install it with pip:
+
+    $ python3 -m pip install PyMySQL
+
+To use "sha256_password" or "caching_sha2_password" for authenticate,
+you need to install additional dependency:
+
+    $ python3 -m pip install PyMySQL[rsa]
+
+To use MariaDB's "ed25519" authentication method, you need to install
+additional dependency:
+
+    $ python3 -m pip install PyMySQL[ed25519]
+
+## Documentation
+
+Documentation is available online: <https://pymysql.readthedocs.io/>
+
+For support, please refer to the
+[StackOverflow](https://stackoverflow.com/questions/tagged/pymysql).
+
+## Example
+
+The following examples make use of a simple table
+
+``` sql
+CREATE TABLE `users` (
+    `id` int(11) NOT NULL AUTO_INCREMENT,
+    `email` varchar(255) COLLATE utf8_bin NOT NULL,
+    `password` varchar(255) COLLATE utf8_bin NOT NULL,
+    PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
+AUTO_INCREMENT=1 ;
+```
+
+``` python
+import pymysql.cursors
+
+# Connect to the database
+connection = pymysql.connect(host='localhost',
+                             user='user',
+                             password='passwd',
+                             database='db',
+                             cursorclass=pymysql.cursors.DictCursor)
+
+with connection:
+    with connection.cursor() as cursor:
+        # Create a new record
+        sql = "INSERT INTO `users` (`email`, `password`) VALUES (%s, %s)"
+        cursor.execute(sql, ('webmaster@python.org', 'very-secret'))
+
+    # connection is not autocommit by default. So you must commit to save
+    # your changes.
+    connection.commit()
+
+    with connection.cursor() as cursor:
+        # Read a single record
+        sql = "SELECT `id`, `password` FROM `users` WHERE `email`=%s"
+        cursor.execute(sql, ('webmaster@python.org',))
+        result = cursor.fetchone()
+        print(result)
+```
+
+This example will print:
+
+``` python
+{'password': 'very-secret', 'id': 1}
+```
+
+## Resources
+
+- DB-API 2.0: <https://www.python.org/dev/peps/pep-0249/>
+- MySQL Reference Manuals: <https://dev.mysql.com/doc/>
+- Getting Help With MariaDB <https://mariadb.com/kb/en/getting-help-with-mariadb/>
+- MySQL client/server protocol:
+  <https://dev.mysql.com/doc/internals/en/client-server-protocol.html>
+- "Connector" channel in MySQL Community Slack:
+  <https://lefred.be/mysql-community-on-slack/>
+- PyMySQL mailing list:
+  <https://groups.google.com/forum/#!forum/pymysql-users>
+
+## License
+
+PyMySQL is released under the MIT License. See LICENSE for more
+information.
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 279181f16..000000000
--- a/README.rst
+++ /dev/null
@@ -1,148 +0,0 @@
-.. image:: https://readthedocs.org/projects/pymysql/badge/?version=latest
-    :target: https://pymysql.readthedocs.io/
-    :alt: Documentation Status
-
-.. image:: https://coveralls.io/repos/PyMySQL/PyMySQL/badge.svg?branch=master&service=github
-    :target: https://coveralls.io/github/PyMySQL/PyMySQL?branch=master
-
-.. image:: https://img.shields.io/lgtm/grade/python/g/PyMySQL/PyMySQL.svg?logo=lgtm&logoWidth=18
-    :target: https://lgtm.com/projects/g/PyMySQL/PyMySQL/context:python
-
-
-PyMySQL
-=======
-
-.. contents:: Table of Contents
-   :local:
-
-This package contains a pure-Python MySQL client library, based on `PEP 249`_.
-
-Most public APIs are compatible with mysqlclient and MySQLdb.
-
-NOTE: PyMySQL doesn't support low level APIs `_mysql` provides like `data_seek`,
-`store_result`, and `use_result`. You should use high level APIs defined in `PEP 249`_.
-But some APIs like `autocommit` and `ping` are supported because `PEP 249`_ doesn't cover
-their usecase.
-
-.. _`PEP 249`: https://www.python.org/dev/peps/pep-0249/
-
-
-Requirements
--------------
-
-* Python -- one of the following:
-
-  - CPython_ : 3.6 and newer
-  - PyPy_ : Latest 3.x version
-
-* MySQL Server -- one of the following:
-
-  - MySQL_ >= 5.6
-  - MariaDB_ >= 10.0
-
-.. _CPython: https://www.python.org/
-.. _PyPy: https://pypy.org/
-.. _MySQL: https://www.mysql.com/
-.. _MariaDB: https://mariadb.org/
-
-
-Installation
-------------
-
-Package is uploaded on `PyPI <https://pypi.org/project/PyMySQL>`_.
-
-You can install it with pip::
-
-    $ python3 -m pip install PyMySQL
-
-To use "sha256_password" or "caching_sha2_password" for authenticate,
-you need to install additional dependency::
-
-   $ python3 -m pip install PyMySQL[rsa]
-
-To use MariaDB's "ed25519" authentication method, you need to install
-additional dependency::
-
-   $ python3 -m pip install PyMySQL[ed25519]
-
-
-Documentation
--------------
-
-Documentation is available online: https://pymysql.readthedocs.io/
-
-For support, please refer to the `StackOverflow
-<https://stackoverflow.com/questions/tagged/pymysql>`_.
-
-
-Example
--------
-
-The following examples make use of a simple table
-
-.. code:: sql
-
-   CREATE TABLE `users` (
-       `id` int(11) NOT NULL AUTO_INCREMENT,
-       `email` varchar(255) COLLATE utf8_bin NOT NULL,
-       `password` varchar(255) COLLATE utf8_bin NOT NULL,
-       PRIMARY KEY (`id`)
-   ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
-   AUTO_INCREMENT=1 ;
-
-
-.. code:: python
-
-    import pymysql.cursors
-
-    # Connect to the database
-    connection = pymysql.connect(host='localhost',
-                                 user='user',
-                                 password='passwd',
-                                 database='db',
-                                 cursorclass=pymysql.cursors.DictCursor)
-
-    with connection:
-        with connection.cursor() as cursor:
-            # Create a new record
-            sql = "INSERT INTO `users` (`email`, `password`) VALUES (%s, %s)"
-            cursor.execute(sql, ('webmaster@python.org', 'very-secret'))
-
-        # connection is not autocommit by default. So you must commit to save
-        # your changes.
-        connection.commit()
-
-        with connection.cursor() as cursor:
-            # Read a single record
-            sql = "SELECT `id`, `password` FROM `users` WHERE `email`=%s"
-            cursor.execute(sql, ('webmaster@python.org',))
-            result = cursor.fetchone()
-            print(result)
-
-
-This example will print:
-
-.. code:: python
-
-    {'password': 'very-secret', 'id': 1}
-
-
-Resources
----------
-
-* DB-API 2.0: https://www.python.org/dev/peps/pep-0249/
-
-* MySQL Reference Manuals: https://dev.mysql.com/doc/
-
-* MySQL client/server protocol:
-  https://dev.mysql.com/doc/internals/en/client-server-protocol.html
-
-* "Connector" channel in MySQL Community Slack:
-  https://lefred.be/mysql-community-on-slack/
-
-* PyMySQL mailing list: https://groups.google.com/forum/#!forum/pymysql-users
-
-License
--------
-
-PyMySQL is released under the MIT License. See LICENSE for more information.
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/docker-entrypoint-initdb.d/README b/ci/docker-entrypoint-initdb.d/README
new file mode 100644
index 000000000..6a54b93da
--- /dev/null
+++ b/ci/docker-entrypoint-initdb.d/README
@@ -0,0 +1,12 @@
+To test with a MariaDB or MySQL container image:
+
+docker run -d -p 3306:3306 -e MYSQL_ALLOW_EMPTY_PASSWORD=1 \
+  --name=mysqld -v ./ci/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:z \
+  mysql:8.0.26 --local-infile=1
+
+cp ci/docker.json pymysql/tests/databases.json
+
+pytest
+
+
+Note: Some authentication tests that don't match the image version will fail.
diff --git a/ci/docker-entrypoint-initdb.d/init.sql b/ci/docker-entrypoint-initdb.d/init.sql
new file mode 100644
index 000000000..b741d41c5
--- /dev/null
+++ b/ci/docker-entrypoint-initdb.d/init.sql
@@ -0,0 +1,7 @@
+create database test1 DEFAULT CHARACTER SET utf8mb4;
+create database test2 DEFAULT CHARACTER SET utf8mb4;
+create user test2           identified by 'some password';
+grant all on test2.* to test2;
+create user test2@localhost identified by 'some password';
+grant all on test2.* to test2@localhost;
+
diff --git a/ci/docker-entrypoint-initdb.d/mariadb.sql b/ci/docker-entrypoint-initdb.d/mariadb.sql
new file mode 100644
index 000000000..912d365a9
--- /dev/null
+++ b/ci/docker-entrypoint-initdb.d/mariadb.sql
@@ -0,0 +1,2 @@
+/*M!100122 INSTALL SONAME "auth_ed25519" */;
+/*M!100122 CREATE FUNCTION ed25519_password RETURNS STRING SONAME "auth_ed25519.so" */;
diff --git a/ci/docker-entrypoint-initdb.d/mysql.sql b/ci/docker-entrypoint-initdb.d/mysql.sql
new file mode 100644
index 000000000..a4ba0927d
--- /dev/null
+++ b/ci/docker-entrypoint-initdb.d/mysql.sql
@@ -0,0 +1,8 @@
+/*!80001 CREATE USER
+                  user_sha256   IDENTIFIED WITH "sha256_password" BY "pass_sha256_01234567890123456789",
+                  nopass_sha256 IDENTIFIED WITH "sha256_password",
+                  user_caching_sha2   IDENTIFIED WITH "caching_sha2_password" BY "pass_caching_sha2_01234567890123456789",
+                  nopass_caching_sha2 IDENTIFIED WITH "caching_sha2_password"
+                  PASSWORD EXPIRE NEVER */;
+
+/*!80001 GRANT RELOAD ON *.* TO user_caching_sha2 */;
diff --git a/ci/docker.json b/ci/docker.json
index 34a5c7b7c..63d19a687 100644
--- a/ci/docker.json
+++ b/ci/docker.json
@@ -1,4 +1,5 @@
 [
     {"host": "127.0.0.1", "port": 3306, "user": "root",  "password": "", "database": "test1",  "use_unicode": true, "local_infile": true},
-    {"host": "127.0.0.1", "port": 3306, "user": "test2", "password": "some password", "database": "test2" }
+    {"host": "127.0.0.1", "port": 3306, "user": "test2", "password": "some password", "database": "test2" },
+    {"host": "localhost", "port": 3306, "user": "test2", "password": "some password", "database": "test2", "unix_socket": "/run/mysqld/mysqld.sock"}
 ]
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 ^<target^>` where ^<target^> 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 77d7073a8..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.
@@ -46,8 +43,8 @@
 master_doc = "index"
 
 # General information about the project.
-project = u"PyMySQL"
-copyright = u"2016, Yutaka Matsubara and GitHub contributors"
+project = "PyMySQL"
+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
@@ -200,8 +197,8 @@
     (
         "index",
         "PyMySQL.tex",
-        u"PyMySQL Documentation",
-        u"Yutaka Matsubara and GitHub contributors",
+        "PyMySQL Documentation",
+        "Yutaka Matsubara and GitHub contributors",
         "manual",
     ),
 ]
@@ -235,8 +232,8 @@
     (
         "index",
         "pymysql",
-        u"PyMySQL Documentation",
-        [u"Yutaka Matsubara and GitHub contributors"],
+        "PyMySQL Documentation",
+        ["Yutaka Matsubara and GitHub contributors"],
         1,
     )
 ]
@@ -254,8 +251,8 @@
     (
         "index",
         "PyMySQL",
-        u"PyMySQL Documentation",
-        u"Yutaka Matsubara and GitHub contributors",
+        "PyMySQL Documentation",
+        "Yutaka Matsubara and GitHub contributors",
         "PyMySQL",
         "One line description of project.",
         "Miscellaneous",
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/docs/source/user/installation.rst b/docs/source/user/installation.rst
index 0fea27266..9313f14d3 100644
--- a/docs/source/user/installation.rst
+++ b/docs/source/user/installation.rst
@@ -18,13 +18,13 @@ Requirements
 
 * Python -- one of the following:
 
-  - CPython_ >= 3.6
+  - CPython_ >= 3.7
   - Latest PyPy_ 3
 
 * MySQL Server -- one of the following:
 
-  - MySQL_ >= 5.6
-  - MariaDB_ >= 10.0
+  - MySQL_ >= 5.7
+  - MariaDB_ >= 10.3
 
 .. _CPython: http://www.python.org/
 .. _PyPy: http://pypy.org/
diff --git a/pymysql/__init__.py b/pymysql/__init__.py
index 5fe2aec54..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, 2, None)
-if VERSION[3] is not None:
-    VERSION_STRING = "%d.%d.%d_%s" % VERSION
-else:
-    VERSION_STRING = "%d.%d.%d" % VERSION[:3]
 threadsafety = 1
 apilevel = "2.0"
 paramstyle = "pyformat"
@@ -109,34 +129,12 @@ def Binary(x):
     return bytes(x)
 
 
-Connect = connect = Connection = connections.Connection
-
-
-def get_client_info():  # for MySQLdb compatibility
-    version = VERSION
-    if VERSION[3] is None:
-        version = VERSION[:3]
-    return ".".join(map(str, version))
-
-
-# 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 33fd9df86..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
@@ -241,7 +247,7 @@ def caching_sha2_password_auth(conn, pkt):
         return pkt
 
     if n != 4:
-        raise OperationalError("caching sha2: Unknwon result for fast auth: %s" % n)
+        raise OperationalError("caching sha2: Unknown result for fast auth: %s" % n)
 
     if DEBUG:
         print("caching sha2: Trying full auth...")
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 92b7a77e5..99fcfcd0b 100644
--- a/pymysql/connections.py
+++ b/pymysql/connections.py
@@ -13,7 +13,7 @@
 from . import _auth
 
 from .charset import charset_by_name, charset_by_id
-from .constants import CLIENT, COMMAND, CR, FIELD_TYPE, SERVER_STATUS
+from .constants import CLIENT, COMMAND, CR, ER, FIELD_TYPE, SERVER_STATUS
 from . import converters
 from .cursors import Cursor
 from .optionfile import Parser
@@ -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,
@@ -61,7 +64,7 @@
 
 DEFAULT_CHARSET = "utf8mb4"
 
-MAX_PACKET_LEN = 2 ** 24 - 1
+MAX_PACKET_LEN = 2**24 - 1
 
 
 def _pack_int24(n):
@@ -84,8 +87,7 @@ def _lenenc_int(i):
         return b"\xfe" + struct.pack("<Q", i)
     else:
         raise ValueError(
-            "Encoding %x is larger than %x - no representation in LengthEncodedInteger"
-            % (i, (1 << 64))
+            f"Encoding {i:x} is larger than {1 << 64:x} - no representation in LengthEncodedInteger"
         )
 
 
@@ -99,18 +101,21 @@ class Connection:
     Establish a connection to the MySQL database. Accepts several
     arguments:
 
-    :param host: Host where the database server is located
-    :param user: Username to log in as
+    :param host: Host where the database server is located.
+    :param user: Username to log in as.
     :param password: Password to use.
     :param database: Database to use, None to not use a particular one.
     :param port: MySQL port to use, default is usually OK. (default: 3306)
     :param bind_address: When the client has multiple network interfaces, specify
         the interface from which to connect to the host. Argument can be
         a hostname or an IP address.
-    :param unix_socket: Optionally, you can use a unix socket rather than TCP/IP.
-    :param read_timeout: The timeout for reading from the connection in seconds (default: None - no timeout)
-    :param write_timeout: The timeout for writing to the connection in seconds (default: None - no timeout)
-    :param charset: Charset you want to use.
+    :param unix_socket: Use a unix socket rather than TCP/IP.
+    :param read_timeout: The timeout for reading from the connection in seconds.
+        (default: None - no timeout)
+    :param write_timeout: The timeout for writing to the connection in seconds.
+        (default: None - no timeout)
+    :param str charset: Charset to use.
+    :param str collation: Collation name to use.
     :param sql_mode: Default SQL_MODE to use.
     :param read_default_file:
         Specifies  my.cnf file to read these parameters from under the [client] section.
@@ -124,16 +129,17 @@ class Connection:
     :param client_flag: Custom flags to send to MySQL. Find potential values in constants.CLIENT.
     :param cursorclass: Custom cursor class to use.
     :param init_command: Initial SQL statement to run when connection is established.
-    :param connect_timeout: Timeout before throwing an exception when connecting.
+    :param connect_timeout: The timeout for connecting to the database in seconds.
         (default: 10, min: 1, max: 31536000)
-    :param ssl:
-        A dict of arguments similar to mysql_ssl_set()'s parameters.
-    :param ssl_ca: Path to the file that contains a PEM-formatted CA certificate
-    :param ssl_cert: Path to the file that contains a PEM-formatted client certificate
-    :param ssl_disabled: A boolean value that disables usage of TLS
-    :param ssl_key: Path to the file that contains a PEM-formatted private key for the client certificate
-    :param ssl_verify_cert: Set to true to check the validity of server certificates
-    :param ssl_verify_identity: Set to true to check the server's identity
+    :param ssl: A dict of arguments similar to mysql_ssl_set()'s parameters or an ssl.SSLContext.
+    :param ssl_ca: Path to the file that contains a PEM-formatted CA certificate.
+    :param ssl_cert: Path to the file that contains a PEM-formatted client certificate.
+    :param ssl_disabled: A boolean value that disables usage of TLS.
+    :param ssl_key: Path to the file that contains a PEM-formatted private key for
+        the client certificate.
+    :param ssl_key_password: The password for the client certificate private key.
+    :param ssl_verify_cert: Set to true to check the server certificate's validity.
+    :param ssl_verify_identity: Set to true to check the server's identity.
     :param read_default_group: Group to read from in the configuration file.
     :param autocommit: Autocommit mode. None means use server default. (default: False)
     :param local_infile: Boolean to enable the use of LOAD DATA LOCAL command. (default: False)
@@ -148,8 +154,8 @@ class Connection:
         (if no authenticate method) for returning a string from the user. (experimental)
     :param server_public_key: SHA256 authentication plugin public key value. (default: None)
     :param binary_prefix: Add _binary prefix on bytes and bytearray. (default: False)
-    :param compress: Not supported
-    :param named_pipe: Not supported
+    :param compress: Not supported.
+    :param named_pipe: Not supported.
     :param db: **DEPRECATED** Alias for database.
     :param passwd: **DEPRECATED** Alias for password.
 
@@ -158,6 +164,7 @@ class Connection:
     """
 
     _sock = None
+    _rfile = None
     _auth_plugin_name = ""
     _closed = False
     _secure = False
@@ -172,6 +179,7 @@ def __init__(
         unix_socket=None,
         port=0,
         charset="",
+        collation=None,
         sql_mode=None,
         read_default_file=None,
         conv=None,
@@ -197,6 +205,7 @@ def __init__(
         ssl_cert=None,
         ssl_disabled=None,
         ssl_key=None,
+        ssl_key_password=None,
         ssl_verify_cert=None,
         ssl_verify_identity=None,
         compress=None,  # not supported
@@ -205,12 +214,12 @@ def __init__(
         db=None,  # deprecated
     ):
         if db is not None and database is None:
-            # We will raise warining in 2022 or later.
+            # We will raise warning in 2022 or later.
             # See https://github.com/PyMySQL/PyMySQL/issues/939
             # warnings.warn("'db' is deprecated, use 'database'", DeprecationWarning, 3)
             database = db
         if passwd is not None and not password:
-            # We will raise warining in 2022 or later.
+            # We will raise warning in 2022 or later.
             # See https://github.com/PyMySQL/PyMySQL/issues/939
             # warnings.warn(
             #    "'passwd' is deprecated, use 'password'", DeprecationWarning, 3
@@ -258,7 +267,7 @@ def _config(key, arg):
             if not ssl:
                 ssl = {}
             if isinstance(ssl, dict):
-                for key in ["ca", "capath", "cert", "key", "cipher"]:
+                for key in ["ca", "capath", "cert", "key", "password", "cipher"]:
                     value = _config("ssl-" + key, ssl.get(key))
                     if value:
                         ssl[key] = value
@@ -277,6 +286,8 @@ def _config(key, arg):
                     ssl["cert"] = ssl_cert
                 if ssl_key is not None:
                     ssl["key"] = ssl_key
+                if ssl_key_password is not None:
+                    ssl["password"] = ssl_key_password
             if ssl:
                 if not SSL_ENABLED:
                     raise NotImplementedError("ssl module not found")
@@ -306,6 +317,7 @@ def _config(key, arg):
         self._write_timeout = write_timeout
 
         self.charset = charset or DEFAULT_CHARSET
+        self.collation = collation
         self.use_unicode = use_unicode
 
         self.encoding = charset_by_name(self.charset).encoding
@@ -340,8 +352,8 @@ def _config(key, arg):
 
         self._connect_attrs = {
             "_client_name": "pymysql",
-            "_pid": str(os.getpid()),
             "_client_version": VERSION_STRING,
+            "_pid": str(os.getpid()),
         }
 
         if program_name:
@@ -366,6 +378,12 @@ def _create_ssl_ctx(self, sslp):
         capath = sslp.get("capath")
         hasnoca = ca is None and capath is None
         ctx = ssl.create_default_context(cafile=ca, capath=capath)
+
+        # Python 3.13 enables VERIFY_X509_STRICT by default.
+        # But self signed certificates that are generated by MySQL automatically
+        # doesn't pass the verification.
+        ctx.verify_flags &= ~ssl.VERIFY_X509_STRICT
+
         ctx.check_hostname = not hasnoca and sslp.get("check_hostname", True)
         verify_mode_value = sslp.get("verify_mode")
         if verify_mode_value is None:
@@ -384,7 +402,9 @@ def _create_ssl_ctx(self, sslp):
             else:
                 ctx.verify_mode = ssl.CERT_NONE if hasnoca else ssl.CERT_REQUIRED
         if "cert" in sslp:
-            ctx.load_cert_chain(sslp["cert"], keyfile=sslp.get("key"))
+            ctx.load_cert_chain(
+                sslp["cert"], keyfile=sslp.get("key"), password=sslp.get("password")
+            )
         if "cipher" in sslp:
             ctx.set_ciphers(sslp["cipher"])
         ctx.options |= ssl.OP_NO_SSLv2
@@ -415,11 +435,13 @@ def close(self):
 
     @property
     def open(self):
-        """Return True if the connection is open"""
+        """Return True if the connection is open."""
         return self._sock is not None
 
     def _force_close(self):
-        """Close connection without QUIT message"""
+        """Close connection without QUIT message."""
+        if self._rfile:
+            self._rfile.close()
         if self._sock:
             try:
                 self._sock.close()
@@ -442,13 +464,16 @@ def get_autocommit(self):
     def _read_ok_packet(self):
         pkt = self._read_packet()
         if not pkt.is_ok_packet():
-            raise err.OperationalError(2014, "Command Out of Sync")
+            raise err.OperationalError(
+                CR.CR_COMMANDS_OUT_OF_SYNC,
+                "Command Out of Sync",
+            )
         ok = OKPacketWrapper(pkt)
         self.server_status = ok.server_status
         return ok
 
     def _send_autocommit_mode(self):
-        """Set whether or not to commit after every execute()"""
+        """Set whether or not to commit after every execute()."""
         self._execute_command(
             COMMAND.COM_QUERY, "SET AUTOCOMMIT = %s" % self.escape(self.autocommit_mode)
         )
@@ -496,7 +521,7 @@ def select_db(self, db):
         self._read_ok_packet()
 
     def escape(self, obj, mapping=None):
-        """Escape whatever value you pass to it.
+        """Escape whatever value is passed.
 
         Non-standard, for internal use; do not use this in your applications.
         """
@@ -510,7 +535,7 @@ def escape(self, obj, mapping=None):
         return converters.escape_item(obj, self.charset, mapping=mapping)
 
     def literal(self, obj):
-        """Alias for escape()
+        """Alias for escape().
 
         Non-standard, for internal use; do not use this in your applications.
         """
@@ -523,16 +548,18 @@ def escape_string(self, s):
 
     def _quote_bytes(self, s):
         if self.server_status & SERVER_STATUS.SERVER_STATUS_NO_BACKSLASH_ESCAPES:
-            return "'%s'" % (s.replace(b"'", b"''").decode("ascii", "surrogateescape"),)
+            return "'{}'".format(
+                s.replace(b"'", b"''").decode("ascii", "surrogateescape")
+            )
         return converters.escape_bytes(s)
 
     def cursor(self, cursor=None):
         """
         Create a new cursor to execute queries with.
 
-        :param cursor: The type of cursor to create; one of :py:class:`Cursor`,
-            :py:class:`SSCursor`, :py:class:`DictCursor`, or :py:class:`SSDictCursor`.
-            None means use Cursor.
+        :param cursor: The type of cursor to create. None means use Cursor.
+        :type cursor: :py:class:`Cursor`, :py:class:`SSCursor`, :py:class:`DictCursor`,
+            or :py:class:`SSDictCursor`.
         """
         if cursor:
             return cursor(self)
@@ -556,15 +583,17 @@ def affected_rows(self):
         return self._affected_rows
 
     def kill(self, thread_id):
-        arg = struct.pack("<I", thread_id)
-        self._execute_command(COMMAND.COM_PROCESS_KILL, arg)
-        return self._read_ok_packet()
+        if not isinstance(thread_id, int):
+            raise TypeError("thread_id must be an integer")
+        self.query(f"KILL {thread_id:d}")
 
     def ping(self, reconnect=True):
         """
         Check if the server is alive.
 
         :param reconnect: If the connection is closed, reconnect.
+        :type reconnect: boolean
+
         :raise Error: If the connection is closed and reconnect=False.
         """
         if self._sock is None:
@@ -584,13 +613,32 @@ def ping(self, reconnect=True):
                 raise
 
     def set_charset(self, charset):
+        """Deprecated. Use set_character_set() instead."""
+        # This function has been implemented in old PyMySQL.
+        # But this name is different from MySQLdb.
+        # So we keep this function for compatibility and add
+        # new set_character_set() function.
+        self.set_character_set(charset)
+
+    def set_character_set(self, charset, collation=None):
+        """
+        Set charaset (and collation)
+
+        Send "SET NAMES charset [COLLATE collation]" query.
+        Update Connection.encoding based on charset.
+        """
         # Make sure charset is supported.
         encoding = charset_by_name(charset).encoding
 
-        self._execute_command(COMMAND.COM_QUERY, "SET NAMES %s" % self.escape(charset))
+        if collation:
+            query = f"SET NAMES {charset} COLLATE {collation}"
+        else:
+            query = f"SET NAMES {charset}"
+        self._execute_command(COMMAND.COM_QUERY, query)
         self._read_packet()
         self.charset = charset
         self.encoding = encoding
+        self.collation = collation
 
     def connect(self, sock=None):
         self._closed = False
@@ -614,7 +662,7 @@ def connect(self, sock=None):
                                 (self.host, self.port), self.connect_timeout, **kwargs
                             )
                             break
-                        except (OSError, IOError) as e:
+                        except OSError as e:
                             if e.errno == errno.EINTR:
                                 continue
                             raise
@@ -632,29 +680,40 @@ def connect(self, sock=None):
             self._get_server_information()
             self._request_authentication()
 
+            # Send "SET NAMES" query on init for:
+            # - Ensure charaset (and collation) is set to the server.
+            #   - collation_id in handshake packet may be ignored.
+            # - If collation is not specified, we don't know what is server's
+            #   default collation for the charset. For example, default collation
+            #   of utf8mb4 is:
+            #   - MySQL 5.7, MariaDB 10.x: utf8mb4_general_ci
+            #   - MySQL 8.0: utf8mb4_0900_ai_ci
+            #
+            # Reference:
+            # - https://github.com/PyMySQL/PyMySQL/issues/1092
+            # - https://github.com/wagtail/wagtail/issues/9477
+            # - https://zenn.dev/methane/articles/2023-mysql-collation (Japanese)
+            self.set_character_set(self.charset, self.collation)
+
             if self.sql_mode is not None:
                 c = self.cursor()
                 c.execute("SET sql_mode=%s", (self.sql_mode,))
+                c.close()
 
             if self.init_command is not None:
                 c = self.cursor()
                 c.execute(self.init_command)
                 c.close()
-                self.commit()
 
             if self.autocommit_mode is not None:
                 self.autocommit(self.autocommit_mode)
         except BaseException as e:
-            self._rfile = None
-            if sock is not None:
-                try:
-                    sock.close()
-                except:  # noqa
-                    pass
+            self._force_close()
 
-            if isinstance(e, (OSError, IOError, socket.error)):
+            if isinstance(e, (OSError, IOError)):
                 exc = err.OperationalError(
-                    2003, "Can't connect to MySQL server on %r (%s)" % (self.host, e)
+                    CR.CR_CONN_HOST_ERROR,
+                    f"Can't connect to MySQL server on {self.host!r} ({e})",
                 )
                 # Keep original exception and traceback to investigate error.
                 exc.original_exception = e
@@ -713,8 +772,6 @@ def _read_packet(self, packet_type=MysqlPacket):
                 dump_packet(recv_data)
             buff += recv_data
             # https://dev.mysql.com/doc/internals/en/sending-more-than-16mbyte.html
-            if bytes_to_read == 0xFFFFFF:
-                continue
             if bytes_to_read < MAX_PACKET_LEN:
                 break
 
@@ -731,13 +788,13 @@ def _read_bytes(self, num_bytes):
             try:
                 data = self._rfile.read(num_bytes)
                 break
-            except (IOError, OSError) as e:
+            except OSError as e:
                 if e.errno == errno.EINTR:
                     continue
                 self._force_close()
                 raise err.OperationalError(
                     CR.CR_SERVER_LOST,
-                    "Lost connection to MySQL server during query (%s)" % (e,),
+                    f"Lost connection to MySQL server during query ({e})",
                 )
             except BaseException:
                 # Don't convert unknown exception to MySQLError.
@@ -754,24 +811,18 @@ def _write_bytes(self, data):
         self._sock.settimeout(self._write_timeout)
         try:
             self._sock.sendall(data)
-        except IOError as e:
+        except OSError as e:
             self._force_close()
             raise err.OperationalError(
-                CR.CR_SERVER_GONE_ERROR, "MySQL server has gone away (%r)" % (e,)
+                CR.CR_SERVER_GONE_ERROR, f"MySQL server has gone away ({e!r})"
             )
 
     def _read_query_result(self, unbuffered=False):
         self._result = None
+        result = MySQLResult(self)
         if unbuffered:
-            try:
-                result = MySQLResult(self)
-                result.init_unbuffered_query()
-            except:
-                result.unbuffered_active = False
-                result.connection = None
-                raise
+            result.init_unbuffered_query()
         else:
-            result = MySQLResult(self)
             result.read()
         self._result = result
         if result.server_status is not None:
@@ -898,10 +949,10 @@ def _request_authentication(self):
             connect_attrs = b""
             for k, v in self._connect_attrs.items():
                 k = k.encode("utf-8")
-                connect_attrs += struct.pack("B", len(k)) + k
+                connect_attrs += _lenenc_int(len(k)) + k
                 v = v.encode("utf-8")
-                connect_attrs += struct.pack("B", len(v)) + v
-            data += struct.pack("B", len(connect_attrs)) + connect_attrs
+                connect_attrs += _lenenc_int(len(v)) + v
+            data += _lenenc_int(len(connect_attrs)) + connect_attrs
 
         self.write_packet(data)
         auth_packet = self._read_packet()
@@ -920,10 +971,7 @@ def _request_authentication(self):
             ):
                 auth_packet = self._process_auth(plugin_name, auth_packet)
             else:
-                # send legacy handshake
-                data = _auth.scramble_old_password(self.password, self.salt) + b"\0"
-                self.write_packet(data)
-                auth_packet = self._read_packet()
+                raise err.OperationalError("received unknown auth switch request")
         elif auth_packet.is_extra_auth_data():
             if DEBUG:
                 print("received extra data")
@@ -948,10 +996,9 @@ def _process_auth(self, plugin_name, auth_packet):
             except AttributeError:
                 if plugin_name != b"dialog":
                     raise err.OperationalError(
-                        2059,
-                        "Authentication plugin '%s'"
-                        " not loaded: - %r missing authenticate method"
-                        % (plugin_name, type(handler)),
+                        CR.CR_AUTH_PLUGIN_CANNOT_LOAD,
+                        f"Authentication plugin '{plugin_name}'"
+                        f" not loaded: - {type(handler)!r} missing authenticate method",
                     )
         if plugin_name == b"caching_sha2_password":
             return _auth.caching_sha2_password_auth(self, auth_packet)
@@ -986,23 +1033,20 @@ def _process_auth(self, plugin_name, auth_packet):
                         self.write_packet(resp + b"\0")
                     except AttributeError:
                         raise err.OperationalError(
-                            2059,
-                            "Authentication plugin '%s'"
-                            " not loaded: - %r missing prompt method"
-                            % (plugin_name, handler),
+                            CR.CR_AUTH_PLUGIN_CANNOT_LOAD,
+                            f"Authentication plugin '{plugin_name}'"
+                            f" not loaded: - {handler!r} missing prompt method",
                         )
                     except TypeError:
                         raise err.OperationalError(
-                            2061,
-                            "Authentication plugin '%s'"
-                            " %r didn't respond with string. Returned '%r' to prompt %r"
-                            % (plugin_name, handler, resp, prompt),
+                            CR.CR_AUTH_PLUGIN_ERR,
+                            f"Authentication plugin '{plugin_name}'"
+                            f" {handler!r} didn't respond with string. Returned '{resp!r}' to prompt {prompt!r}",
                         )
                 else:
                     raise err.OperationalError(
-                        2059,
-                        "Authentication plugin '%s' (%r) not configured"
-                        % (plugin_name, handler),
+                        CR.CR_AUTH_PLUGIN_CANNOT_LOAD,
+                        f"Authentication plugin '{plugin_name}' not configured",
                     )
                 pkt = self._read_packet()
                 pkt.check_error()
@@ -1011,7 +1055,8 @@ def _process_auth(self, plugin_name, auth_packet):
             return pkt
         else:
             raise err.OperationalError(
-                2059, "Authentication plugin '%s' not configured" % plugin_name
+                CR.CR_AUTH_PLUGIN_CANNOT_LOAD,
+                "Authentication plugin '%s' not configured" % plugin_name,
             )
 
         self.write_packet(data)
@@ -1028,10 +1073,9 @@ def _get_auth_plugin_handler(self, plugin_name):
                 handler = plugin_class(self)
             except TypeError:
                 raise err.OperationalError(
-                    2059,
-                    "Authentication plugin '%s'"
-                    " not loaded: - %r cannot be constructed with connection object"
-                    % (plugin_name, plugin_class),
+                    CR.CR_AUTH_PLUGIN_CANNOT_LOAD,
+                    f"Authentication plugin '{plugin_name}'"
+                    f" not loaded: - {plugin_class!r} cannot be constructed with connection object",
                 )
         else:
             handler = None
@@ -1115,6 +1159,9 @@ def _get_server_information(self):
             else:
                 self._auth_plugin_name = data[i:server_end].decode("utf-8")
 
+        if _DEFAULT_AUTH_PLUGIN is not None:  # for tests
+            self._auth_plugin_name = _DEFAULT_AUTH_PLUGIN
+
     def get_server_info(self):
         return self.server_version
 
@@ -1169,17 +1216,16 @@ def init_unbuffered_query(self):
         :raise OperationalError: If the connection to the MySQL server is lost.
         :raise InternalError:
         """
-        self.unbuffered_active = True
         first_packet = self.connection._read_packet()
 
         if first_packet.is_ok_packet():
-            self._read_ok_packet(first_packet)
-            self.unbuffered_active = False
             self.connection = None
+            self._read_ok_packet(first_packet)
         elif first_packet.is_load_local_packet():
-            self._read_load_local_packet(first_packet)
-            self.unbuffered_active = False
-            self.connection = None
+            try:
+                self._read_load_local_packet(first_packet)
+            finally:
+                self.connection = None
         else:
             self.field_count = first_packet.read_length_encoded_integer()
             self._get_descriptions()
@@ -1188,6 +1234,7 @@ def init_unbuffered_query(self):
             # value of a 64bit unsigned integer. Since we're emulating MySQLdb,
             # we set it to this instead of None, which would be preferred.
             self.affected_rows = 18446744073709551615
+            self.unbuffered_active = True
 
     def _read_ok_packet(self, first_packet):
         ok_packet = OKPacketWrapper(first_packet)
@@ -1215,7 +1262,10 @@ def _read_load_local_packet(self, first_packet):
         if (
             not ok_packet.is_ok_packet()
         ):  # pragma: no cover - upstream induced protocol error
-            raise err.OperationalError(2014, "Commands Out of Sync")
+            raise err.OperationalError(
+                CR.CR_COMMANDS_OUT_OF_SYNC,
+                "Commands Out of Sync",
+            )
         self._read_ok_packet(ok_packet)
 
     def _check_packet_is_eof(self, packet):
@@ -1224,7 +1274,8 @@ def _check_packet_is_eof(self, packet):
         # TODO: Support CLIENT.DEPRECATE_EOF
         # 1) Add DEPRECATE_EOF to CAPABILITIES
         # 2) Mask CAPABILITIES with server_capabilities
-        # 3) if server_capabilities & CLIENT.DEPRECATE_EOF: use OKPacketWrapper instead of EOFPacketWrapper
+        # 3) if server_capabilities & CLIENT.DEPRECATE_EOF:
+        #    use OKPacketWrapper instead of EOFPacketWrapper
         wp = EOFPacketWrapper(packet)
         self.warning_count = wp.warning_count
         self.has_next = wp.has_next
@@ -1258,7 +1309,20 @@ def _finish_unbuffered_query(self):
         # in fact, no way to stop MySQL from sending all the data after
         # executing a query, so we just spin, and wait for an EOF packet.
         while self.unbuffered_active:
-            packet = self.connection._read_packet()
+            try:
+                packet = self.connection._read_packet()
+            except err.OperationalError as e:
+                if e.args[0] in (
+                    ER.QUERY_TIMEOUT,
+                    ER.STATEMENT_TIMEOUT,
+                ):
+                    # if the query timed out we can simply ignore this error
+                    self.unbuffered_active = False
+                    self.connection = None
+                    return
+
+                raise
+
             if self._check_packet_is_eof(packet):
                 self.unbuffered_active = False
                 self.connection = None  # release reference to kill cyclic reference.
@@ -1348,7 +1412,7 @@ def send_data(self):
         """Send data packets from the local file to the server"""
         if not self.connection._sock:
             raise err.InterfaceError(0, "")
-        conn = self.connection
+        conn: Connection = self.connection
 
         try:
             with open(self.filename, "rb") as open_file:
@@ -1360,8 +1424,12 @@ def send_data(self):
                     if not chunk:
                         break
                     conn.write_packet(chunk)
-        except IOError:
-            raise err.OperationalError(1017, f"Can't find file '{self.filename}'")
+        except OSError:
+            raise err.OperationalError(
+                ER.FILE_NOT_FOUND,
+                f"Can't find file '{self.filename}'",
+            )
         finally:
-            # send the empty packet to signify we are done sending data
-            conn.write_packet(b"")
+            if not conn._closed:
+                # send the empty packet to signify we are done sending data
+                conn.write_packet(b"")
diff --git a/pymysql/constants/CR.py b/pymysql/constants/CR.py
index 25579a7c6..deae977e5 100644
--- a/pymysql/constants/CR.py
+++ b/pymysql/constants/CR.py
@@ -65,4 +65,15 @@
 CR_AUTH_PLUGIN_CANNOT_LOAD = 2059
 CR_DUPLICATE_CONNECTION_ATTR = 2060
 CR_AUTH_PLUGIN_ERR = 2061
-CR_ERROR_LAST = 2061
+CR_INSECURE_API_ERR = 2062
+CR_FILE_NAME_TOO_LONG = 2063
+CR_SSL_FIPS_MODE_ERR = 2064
+CR_DEPRECATED_COMPRESSION_NOT_SUPPORTED = 2065
+CR_COMPRESSION_WRONGLY_CONFIGURED = 2066
+CR_KERBEROS_USER_NOT_FOUND = 2067
+CR_LOAD_DATA_LOCAL_INFILE_REJECTED = 2068
+CR_LOAD_DATA_LOCAL_INFILE_REALPATH_FAIL = 2069
+CR_DNS_SRV_LOOKUP_FAILED = 2070
+CR_MANDATORY_TRACKER_NOT_FOUND = 2071
+CR_INVALID_FACTOR_NO = 2072
+CR_ERROR_LAST = 2072
diff --git a/pymysql/constants/ER.py b/pymysql/constants/ER.py
index ddcc4e907..98729d12d 100644
--- a/pymysql/constants/ER.py
+++ b/pymysql/constants/ER.py
@@ -470,5 +470,8 @@
 WRONG_STRING_LENGTH = 1468
 ERROR_LAST = 1468
 
+# MariaDB only
+STATEMENT_TIMEOUT = 1969
+QUERY_TIMEOUT = 3024
 # https://github.com/PyMySQL/PyMySQL/issues/607
 CONSTRAINT_FAILED = 4025
diff --git a/pymysql/converters.py b/pymysql/converters.py
index d910f5c5c..dbf97ca75 100644
--- a/pymysql/converters.py
+++ b/pymysql/converters.py
@@ -27,11 +27,7 @@ def escape_item(val, charset, mapping=None):
 
 
 def escape_dict(val, charset, mapping=None):
-    n = {}
-    for k, v in val.items():
-        quoted = escape_item(v, charset, mapping)
-        n[k] = quoted
-    return n
+    raise TypeError("dict can not be used as parameter")
 
 
 def escape_sequence(val, charset, mapping=None):
@@ -56,7 +52,7 @@ def escape_int(value, mapping=None):
 
 def escape_float(value, mapping=None):
     s = repr(value)
-    if s in ("inf", "nan"):
+    if s in ("inf", "-inf", "nan"):
         raise ProgrammingError("%s can not be used with MySQL" % s)
     if "e" not in s:
         s += "e0"
@@ -120,7 +116,10 @@ def escape_time(obj, mapping=None):
 
 def escape_datetime(obj, mapping=None):
     if obj.microsecond:
-        fmt = "'{0.year:04}-{0.month:02}-{0.day:02} {0.hour:02}:{0.minute:02}:{0.second:02}.{0.microsecond:06}'"
+        fmt = (
+            "'{0.year:04}-{0.month:02}-{0.day:02}"
+            + " {0.hour:02}:{0.minute:02}:{0.second:02}.{0.microsecond:06}'"
+        )
     else:
         fmt = "'{0.year:04}-{0.month:02}-{0.day:02} {0.hour:02}:{0.minute:02}:{0.second:02}'"
     return fmt.format(obj)
@@ -155,18 +154,17 @@ def _convert_second_fraction(s):
 def convert_datetime(obj):
     """Returns a DATETIME or TIMESTAMP column value as a datetime object:
 
-      >>> datetime_or_None('2007-02-25 23:06:20')
+      >>> convert_datetime('2007-02-25 23:06:20')
       datetime.datetime(2007, 2, 25, 23, 6, 20)
-      >>> datetime_or_None('2007-02-25T23:06:20')
+      >>> convert_datetime('2007-02-25T23:06:20')
       datetime.datetime(2007, 2, 25, 23, 6, 20)
 
-    Illegal values are returned as None:
-
-      >>> datetime_or_None('2007-02-31T23:06:20') is None
-      True
-      >>> datetime_or_None('0000-00-00 00:00:00') is None
-      True
+    Illegal values are returned as str:
 
+      >>> convert_datetime('2007-02-31T23:06:20')
+      '2007-02-31T23:06:20'
+      >>> convert_datetime('0000-00-00 00:00:00')
+      '0000-00-00 00:00:00'
     """
     if isinstance(obj, (bytes, bytearray)):
         obj = obj.decode("ascii")
@@ -189,15 +187,15 @@ def convert_datetime(obj):
 def convert_timedelta(obj):
     """Returns a TIME column as a timedelta object:
 
-      >>> timedelta_or_None('25:06:17')
-      datetime.timedelta(1, 3977)
-      >>> timedelta_or_None('-25:06:17')
-      datetime.timedelta(-2, 83177)
+      >>> convert_timedelta('25:06:17')
+      datetime.timedelta(days=1, seconds=3977)
+      >>> convert_timedelta('-25:06:17')
+      datetime.timedelta(days=-2, seconds=82423)
 
-    Illegal values are returned as None:
+    Illegal values are returned as string:
 
-      >>> timedelta_or_None('random crap') is None
-      True
+      >>> convert_timedelta('random crap')
+      'random crap'
 
     Note that MySQL always returns TIME columns as (+|-)HH:MM:SS, but
     can accept values as (+|-)DD HH:MM:SS. The latter format will not
@@ -236,15 +234,15 @@ def convert_timedelta(obj):
 def convert_time(obj):
     """Returns a TIME column as a time object:
 
-      >>> time_or_None('15:06:17')
+      >>> convert_time('15:06:17')
       datetime.time(15, 6, 17)
 
-    Illegal values are returned as None:
+    Illegal values are returned as str:
 
-      >>> time_or_None('-25:06:17') is None
-      True
-      >>> time_or_None('random crap') is None
-      True
+      >>> convert_time('-25:06:17')
+      '-25:06:17'
+      >>> convert_time('random crap')
+      'random crap'
 
     Note that MySQL always returns TIME columns as (+|-)HH:MM:SS, but
     can accept values as (+|-)DD HH:MM:SS. The latter format will not
@@ -279,16 +277,15 @@ def convert_time(obj):
 def convert_date(obj):
     """Returns a DATE column as a date object:
 
-      >>> date_or_None('2007-02-26')
+      >>> convert_date('2007-02-26')
       datetime.date(2007, 2, 26)
 
-    Illegal values are returned as None:
-
-      >>> date_or_None('2007-02-31') is None
-      True
-      >>> date_or_None('0000-00-00') is None
-      True
+    Illegal values are returned as str:
 
+      >>> convert_date('2007-02-31')
+      '2007-02-31'
+      >>> convert_date('0000-00-00')
+      '0000-00-00'
     """
     if isinstance(obj, (bytes, bytearray)):
         obj = obj.decode("ascii")
@@ -362,3 +359,5 @@ def through(x):
 conversions = encoders.copy()
 conversions.update(decoders)
 Thing2Literal = escape_str
+
+# Run doctests with `pytest --doctest-modules pymysql/converters.py`
diff --git a/pymysql/cursors.py b/pymysql/cursors.py
index 666970b98..8be05ca23 100644
--- a/pymysql/cursors.py
+++ b/pymysql/cursors.py
@@ -1,4 +1,5 @@
 import re
+import warnings
 from . import err
 
 
@@ -15,7 +16,7 @@
 
 class Cursor:
     """
-    This is the object you use to interact with the database.
+    This is the object used to interact with the database.
 
     Do not create an instance of a Cursor yourself. Call
     connections.Connection.cursor().
@@ -32,6 +33,7 @@ class Cursor:
 
     def __init__(self, connection):
         self.connection = connection
+        self.warning_count = 0
         self.description = None
         self.rownumber = 0
         self.rowcount = -1
@@ -79,7 +81,7 @@ def setoutputsizes(self, *args):
         """Does nothing, required by DB API."""
 
     def _nextset(self, unbuffered=False):
-        """Get the next query set"""
+        """Get the next query set."""
         conn = self._get_db()
         current_result = self._result
         if current_result is None or current_result is not conn._result:
@@ -95,13 +97,6 @@ def _nextset(self, unbuffered=False):
     def nextset(self):
         return self._nextset(False)
 
-    def _ensure_bytes(self, x, encoding=None):
-        if isinstance(x, str):
-            x = x.encode(encoding)
-        elif isinstance(x, (tuple, list)):
-            x = type(x)(self._ensure_bytes(v, encoding=encoding) for v in x)
-        return x
-
     def _escape_args(self, args, conn):
         if isinstance(args, (tuple, list)):
             return tuple(conn.literal(arg) for arg in args)
@@ -114,9 +109,18 @@ def _escape_args(self, args, conn):
 
     def mogrify(self, query, args=None):
         """
-        Returns the exact string that is sent to the database by calling the
+        Returns the exact string that would be sent to the database by calling the
         execute() method.
 
+        :param query: Query to mogrify.
+        :type query: str
+
+        :param args: Parameters used with query. (optional)
+        :type args: tuple, list or dict
+
+        :return: The query with argument binding applied.
+        :rtype: str
+
         This method follows the extension to the DB API 2.0 followed by Psycopg.
         """
         conn = self._get_db()
@@ -127,14 +131,15 @@ def mogrify(self, query, args=None):
         return query
 
     def execute(self, query, args=None):
-        """Execute a query
+        """Execute a query.
 
-        :param str query: Query to execute.
+        :param query: Query to execute.
+        :type query: str
 
-        :param args: parameters used with query. (optional)
+        :param args: Parameters used with query. (optional)
         :type args: tuple, list or dict
 
-        :return: Number of affected rows
+        :return: Number of affected rows.
         :rtype: int
 
         If args is a list or tuple, %s can be used as a placeholder in the query.
@@ -150,12 +155,16 @@ def execute(self, query, args=None):
         return result
 
     def executemany(self, query, args):
-        # type: (str, list) -> int
-        """Run several data against one query
+        """Run several data against one query.
+
+        :param query: Query to execute.
+        :type query: str
+
+        :param args: Sequence of sequences or mappings. It is used as parameter.
+        :type args: tuple or list
 
-        :param query: query to execute on server
-        :param args:  Sequence of sequences or mappings.  It is used as parameter.
         :return: Number of rows affected, if any.
+        :rtype: int or None
 
         This method improves performance on multiple-row INSERT and
         REPLACE. Otherwise it is equivalent to looping over args with
@@ -213,11 +222,13 @@ def _do_execute_many(
         return rows
 
     def callproc(self, procname, args=()):
-        """Execute stored procedure procname with args
+        """Execute stored procedure procname with args.
 
-        procname -- string, name of procedure to execute on server
+        :param procname: Name of procedure to execute on server.
+        :type procname: str
 
-        args -- Sequence of parameters to use with procedure
+        :param args: Sequence of parameters to use with procedure.
+        :type args: tuple or list
 
         Returns the original args.
 
@@ -251,7 +262,7 @@ def callproc(self, procname, args=()):
             )
             self.nextset()
 
-        q = "CALL %s(%s)" % (
+        q = "CALL {}({})".format(
             procname,
             ",".join(["@_%s_%d" % (procname, i) for i in range(len(args))]),
         )
@@ -260,7 +271,7 @@ def callproc(self, procname, args=()):
         return args
 
     def fetchone(self):
-        """Fetch the next row"""
+        """Fetch the next row."""
         self._check_executed()
         if self._rows is None or self.rownumber >= len(self._rows):
             return None
@@ -269,9 +280,11 @@ def fetchone(self):
         return result
 
     def fetchmany(self, size=None):
-        """Fetch several rows"""
+        """Fetch several rows."""
         self._check_executed()
         if self._rows is None:
+            # Django expects () for EOF.
+            # https://github.com/django/django/blob/0c1518ee429b01c145cf5b34eab01b0b92f8c246/django/db/backends/mysql/features.py#L8
             return ()
         end = self.rownumber + (size or self.arraysize)
         result = self._rows[self.rownumber : end]
@@ -279,10 +292,10 @@ def fetchmany(self, size=None):
         return result
 
     def fetchall(self):
-        """Fetch all the rows"""
+        """Fetch all the rows."""
         self._check_executed()
         if self._rows is None:
-            return ()
+            return []
         if self.rownumber:
             result = self._rows[self.rownumber :]
         else:
@@ -305,7 +318,6 @@ def scroll(self, value, mode="relative"):
 
     def _query(self, q):
         conn = self._get_db()
-        self._last_executed = q
         self._clear_result()
         conn.query(q)
         self._do_get_result()
@@ -316,6 +328,7 @@ def _clear_result(self):
         self._result = None
 
         self.rowcount = 0
+        self.warning_count = 0
         self.description = None
         self.lastrowid = None
         self._rows = None
@@ -326,23 +339,43 @@ def _do_get_result(self):
         self._result = result = conn._result
 
         self.rowcount = result.affected_rows
+        self.warning_count = result.warning_count
         self.description = result.description
         self.lastrowid = result.insert_id
         self._rows = result.rows
 
     def __iter__(self):
-        return iter(self.fetchone, None)
+        return self
+
+    def __next__(self):
+        row = self.fetchone()
+        if row is None:
+            raise StopIteration
+        return row
 
-    Warning = err.Warning
-    Error = err.Error
-    InterfaceError = err.InterfaceError
-    DatabaseError = err.DatabaseError
-    DataError = err.DataError
-    OperationalError = err.OperationalError
-    IntegrityError = err.IntegrityError
-    InternalError = err.InternalError
-    ProgrammingError = err.ProgrammingError
-    NotSupportedError = err.NotSupportedError
+    def __getattr__(self, name):
+        # DB-API 2.0 optional extension says these errors can be accessed
+        # via Connection object. But MySQLdb had defined them on Cursor object.
+        if name in (
+            "Warning",
+            "Error",
+            "InterfaceError",
+            "DatabaseError",
+            "DataError",
+            "OperationalError",
+            "IntegrityError",
+            "InternalError",
+            "ProgrammingError",
+            "NotSupportedError",
+        ):
+            # Deprecated since v1.1
+            warnings.warn(
+                "PyMySQL errors hould be accessed from `pymysql` package",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            return getattr(err, name)
+        raise AttributeError(name)
 
 
 class DictCursorMixin:
@@ -350,7 +383,7 @@ class DictCursorMixin:
     dict_type = dict
 
     def _do_get_result(self):
-        super(DictCursorMixin, self)._do_get_result()
+        super()._do_get_result()
         fields = []
         if self.description:
             for f in self._result.fields:
@@ -410,7 +443,6 @@ def close(self):
 
     def _query(self, q):
         conn = self._get_db()
-        self._last_executed = q
         self._clear_result()
         conn.query(q, unbuffered=True)
         self._do_get_result()
@@ -420,14 +452,15 @@ def nextset(self):
         return self._nextset(unbuffered=True)
 
     def read_next(self):
-        """Read next row"""
+        """Read next row."""
         return self._conv_row(self._result._read_rowdata_packet_unbuffered())
 
     def fetchone(self):
-        """Fetch next row"""
+        """Fetch next row."""
         self._check_executed()
         row = self.read_next()
         if row is None:
+            self.warning_count = self._result.warning_count
             return None
         self.rownumber += 1
         return row
@@ -448,11 +481,8 @@ def fetchall_unbuffered(self):
         """
         return iter(self.fetchone, None)
 
-    def __iter__(self):
-        return self.fetchall_unbuffered()
-
     def fetchmany(self, size=None):
-        """Fetch many"""
+        """Fetch many."""
         self._check_executed()
         if size is None:
             size = self.arraysize
@@ -461,9 +491,14 @@ def fetchmany(self, size=None):
         for i in range(size):
             row = self.read_next()
             if row is None:
+                self.warning_count = self._result.warning_count
                 break
             rows.append(row)
             self.rownumber += 1
+        if not rows:
+            # Django expects () for EOF.
+            # https://github.com/django/django/blob/0c1518ee429b01c145cf5b34eab01b0b92f8c246/django/db/backends/mysql/features.py#L8
+            return ()
         return rows
 
     def scroll(self, value, mode="relative"):
diff --git a/pymysql/err.py b/pymysql/err.py
index 3da5b166f..dac65d3be 100644
--- a/pymysql/err.py
+++ b/pymysql/err.py
@@ -136,7 +136,14 @@ def _map_error(exc, *errors):
 
 def raise_mysql_exception(data):
     errno = struct.unpack("<h", data[1:3])[0]
-    errval = data[9:].decode("utf-8", "replace")
+    # https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_err_packet.html
+    # Error packet has optional sqlstate that is 5 bytes and starts with '#'.
+    if data[3] == 0x23:  # '#'
+        # sqlstate = data[4:9].decode()
+        # TODO: Append (sqlstate) in the error message. This will be come in next minor release.
+        errval = data[9:].decode("utf-8", "replace")
+    else:
+        errval = data[3:].decode("utf-8", "replace")
     errorclass = error_map.get(errno)
     if errorclass is None:
         errorclass = InternalError if errno < 1000 else OperationalError
diff --git a/pymysql/optionfile.py b/pymysql/optionfile.py
index 432621b72..c36f16255 100644
--- a/pymysql/optionfile.py
+++ b/pymysql/optionfile.py
@@ -13,6 +13,9 @@ def __remove_quotes(self, value):
                 return value[1:-1]
         return value
 
+    def optionxform(self, key):
+        return key.lower().replace("_", "-")
+
     def get(self, section, option):
         value = configparser.RawConfigParser.get(self, section, option)
         return self.__remove_quotes(value)
diff --git a/pymysql/protocol.py b/pymysql/protocol.py
index 41c816736..98fde6d0c 100644
--- a/pymysql/protocol.py
+++ b/pymysql/protocol.py
@@ -35,7 +35,7 @@ def printable(data):
     dump_data = [data[i : i + 16] for i in range(0, min(len(data), 256), 16)]
     for d in dump_data:
         print(
-            " ".join("{:02X}".format(x) for x in d)
+            " ".join(f"{x:02X}" for x in d)
             + "   " * (16 - len(d))
             + " " * 2
             + "".join(printable(x) for x in d)
@@ -65,8 +65,7 @@ def read(self, size):
         if len(result) != size:
             error = (
                 "Result length not requested length:\n"
-                "Expected=%s.  Actual=%s.  Position: %s.  Data Length: %s"
-                % (size, len(result), self._position, len(self._data))
+                f"Expected={size}.  Actual={len(result)}.  Position: {self._position}.  Data Length: {len(self._data)}"
             )
             if DEBUG:
                 print(error)
@@ -89,8 +88,7 @@ def advance(self, length):
         new_position = self._position + length
         if new_position < 0 or new_position > 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 6f93a8317..6dfa9590a 100644
--- a/pymysql/tests/base.py
+++ b/pymysql/tests/base.py
@@ -1,4 +1,3 @@
-import gc
 import json
 import os
 import re
@@ -32,6 +31,11 @@ def mysql_server_is(self, conn, version_tuple):
         """Return True if the given connection is on the version given or
         greater.
 
+        This only checks the server version string provided when the
+        connection is established, therefore any check for a version tuple
+        greater than (5, 5, 5) will always fail on MariaDB, as it always
+        starts with 5.5.5, e.g. 5.5.5-10.7.1-MariaDB-1:10.7.1+maria~focal.
+
         e.g.::
 
             if self.mysql_server_is(conn, (5, 6, 4)):
@@ -44,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
@@ -86,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:
@@ -96,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 581a0c4ae..4e545792a 100644
--- a/pymysql/tests/test_DictCursor.py
+++ b/pymysql/tests/test_DictCursor.py
@@ -13,11 +13,11 @@ 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)
 
-        # create a table ane some data to query
+        # create a table and some data to query
         with warnings.catch_warnings():
             warnings.filterwarnings("ignore")
             c.execute("drop table if exists dictcursor")
@@ -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 c2590bf2f..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"]
@@ -14,11 +13,26 @@
 
 class TestConversion(base.PyMySQLTestCase):
     def test_datatypes(self):
-        """ test every data type """
+        """test every data type"""
         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")
@@ -80,7 +96,7 @@ def test_datatypes(self):
             c.execute("drop table test_datatypes")
 
     def test_dict(self):
-        """ test dict escaping """
+        """test dict escaping"""
         conn = self.connect()
         c = conn.cursor()
         c.execute("create table test_dict (a integer, b integer, c integer)")
@@ -143,7 +159,7 @@ def test_blob(self):
             self.assertEqual(data, c.fetchone()[0])
 
     def test_untyped(self):
-        """ test conversion of null, empty string """
+        """test conversion of null, empty string"""
         conn = self.connect()
         c = conn.cursor()
         c.execute("select null,''")
@@ -152,11 +168,12 @@ def test_untyped(self):
         self.assertEqual(("", None), c.fetchone())
 
     def test_timedelta(self):
-        """ test timedelta conversion """
+        """test timedelta conversion"""
         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(
             (
@@ -172,11 +189,9 @@ def test_timedelta(self):
         )
 
     def test_datetime_microseconds(self):
-        """ test datetime conversion w microseconds"""
+        """test datetime conversion w microseconds"""
 
         conn = self.connect()
-        if not self.mysql_server_is(conn, (5, 6, 4)):
-            pytest.skip("target backend does not support microseconds")
         c = conn.cursor()
         dt = datetime.datetime(2013, 11, 12, 9, 9, 9, 123450)
         c.execute("create table test_datetime (id int, ts datetime(6))")
@@ -243,7 +258,7 @@ class TestCursor(base.PyMySQLTestCase):
     #    self.assertEqual(r, c.description)
 
     def test_fetch_no_result(self):
-        """ test a fetchone() with no rows """
+        """test a fetchone() with no rows"""
         conn = self.connect()
         c = conn.cursor()
         c.execute("create table test_nr (b varchar(32))")
@@ -255,7 +270,7 @@ def test_fetch_no_result(self):
             c.execute("drop table test_nr")
 
     def test_aggregates(self):
-        """ test aggregate functions """
+        """test aggregate functions"""
         conn = self.connect()
         c = conn.cursor()
         try:
@@ -269,7 +284,7 @@ def test_aggregates(self):
             c.execute("drop table test_aggregates")
 
     def test_single_tuple(self):
-        """ test a single tuple """
+        """test a single tuple"""
         conn = self.connect()
         c = conn.cursor()
         self.safe_create_table(
@@ -285,8 +300,10 @@ def test_json(self):
         args = self.databases[0].copy()
         args["charset"] = "utf8mb4"
         conn = pymysql.connect(**args)
+        # MariaDB only has limited JSON support, stores data as longtext
+        # https://mariadb.com/kb/en/json-data-type/
         if not self.mysql_server_is(conn, (5, 7, 0)):
-            pytest.skip("JSON type is not supported on MySQL <= 5.6")
+            pytest.skip("JSON type is only supported on MySQL >= 5.7")
 
         self.safe_create_table(
             conn,
@@ -306,21 +323,20 @@ 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 ane some data to query
+        # create a table and some data to query
         self.safe_create_table(
             conn,
             "bulkinsert",
@@ -349,11 +365,11 @@ 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(
-            cursor._last_executed,
+            cursor._executed,
             bytearray(
                 b"insert into bulkinsert (id, name, age, height) values "
                 b"(0,'bob',21,123),(1,'jim',56,45),(2,'fred',100,180)"
@@ -377,7 +393,7 @@ def test_bulk_insert_multiline_statement(self):
             data,
         )
         self.assertEqual(
-            cursor._last_executed.strip(),
+            cursor._executed.strip(),
             bytearray(
                 b"""insert
 into bulkinsert (id, name,
@@ -399,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)]
@@ -422,7 +438,7 @@ def test_issue_288(self):
             data,
         )
         self.assertEqual(
-            cursor._last_executed.strip(),
+            cursor._executed.strip(),
             bytearray(
                 b"""insert
 into bulkinsert (id, name,
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 75db73cd0..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,13 +38,12 @@ 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)
 
 
 class TestAuthentication(base.PyMySQLTestCase):
-
     socket_auth = False
     socket_found = False
     two_questions_found = False
@@ -53,6 +51,7 @@ class TestAuthentication(base.PyMySQLTestCase):
     pam_found = False
     mysql_old_password_found = False
     sha256_password_found = False
+    ed25519_found = False
 
     import os
 
@@ -97,13 +96,13 @@ class TestAuthentication(base.PyMySQLTestCase):
             mysql_old_password_found = True
         elif r[0] == "sha256_password":
             sha256_password_found = True
+        elif r[0] == "ed25519":
+            ed25519_found = True
         # else:
         #    print("plugin: %r" % r[0])
 
     def test_plugin(self):
         conn = self.connect()
-        if not self.mysql_server_is(conn, (5, 5, 0)):
-            pytest.skip("MySQL-5.5 required for plugins")
         cur = conn.cursor()
         cur.execute(
             "select plugin from mysql.user where concat(user, '@', host)=current_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,13 +219,13 @@ 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(
                 user="pymysql_2q",
                 auth_plugin_map={b"dialog": TestAuthentication.Dialog},
-                **self.db
+                **self.db,
             )
 
     @pytest.mark.skipif(not socket_auth, reason="connection to unix_socket required")
@@ -262,16 +261,16 @@ def realTestDialogAuthThreeAttempts(self):
             self.databases[0]["database"],
             "three_attempts",
             "stillnotverysecret",
-        ) as u:
+        ):
             pymysql.connect(
                 user="pymysql_3a",
                 auth_plugin_map={b"dialog": TestAuthentication.Dialog},
-                **self.db
+                **self.db,
             )
             pymysql.connect(
                 user="pymysql_3a",
                 auth_plugin_map={b"dialog": TestAuthentication.DialogHandler},
-                **self.db
+                **self.db,
             )
             with self.assertRaises(pymysql.err.OperationalError):
                 pymysql.connect(
@@ -282,27 +281,27 @@ def realTestDialogAuthThreeAttempts(self):
                 pymysql.connect(
                     user="pymysql_3a",
                     auth_plugin_map={b"dialog": TestAuthentication.DefectiveHandler},
-                    **self.db
+                    **self.db,
                 )
             with self.assertRaises(pymysql.err.OperationalError):
                 pymysql.connect(
                     user="pymysql_3a",
                     auth_plugin_map={b"notdialogplugin": TestAuthentication.Dialog},
-                    **self.db
+                    **self.db,
                 )
             TestAuthentication.Dialog.m = {b"Password, please:": b"I do not know"}
             with self.assertRaises(pymysql.err.OperationalError):
                 pymysql.connect(
                     user="pymysql_3a",
                     auth_plugin_map={b"dialog": TestAuthentication.Dialog},
-                    **self.db
+                    **self.db,
                 )
             TestAuthentication.Dialog.m = {b"Password, please:": None}
             with self.assertRaises(pymysql.err.OperationalError):
                 pymysql.connect(
                     user="pymysql_3a",
                     auth_plugin_map={b"dialog": TestAuthentication.Dialog},
-                    **self.db
+                    **self.db,
                 )
 
     @pytest.mark.skipif(not socket_auth, reason="connection to unix_socket required")
@@ -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(
@@ -367,18 +366,19 @@ def realTestPamAuth(self):
                         auth_plugin_map={
                             b"mysql_cleartext_password": TestAuthentication.DefectiveHandler
                         },
-                        **self.db
+                        **self.db,
                     )
             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,
                         auth_plugin_map={
                             b"mysql_cleartext_password": TestAuthentication.DefectiveHandler
                         },
-                        **self.db
+                        **self.db,
                     )
         if grants:
             # recreate the user
@@ -397,28 +397,66 @@ def testAuthSHA256(self):
             "pymysql_sha256@localhost",
             self.databases[0]["database"],
             "sha256_password",
-        ) as u:
-            if self.mysql_server_is(conn, (5, 7, 0)):
-                c.execute("SET PASSWORD FOR 'pymysql_sha256'@'localhost' ='Sh@256Pa33'")
-            else:
-                c.execute("SET old_passwords = 2")
-                c.execute(
-                    "SET PASSWORD FOR 'pymysql_sha256'@'localhost' = PASSWORD('Sh@256Pa33')"
-                )
+        ):
+            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)
 
+    @pytest.mark.skipif(not ed25519_found, reason="no ed25519 authention plugin")
+    def testAuthEd25519(self):
+        db = self.db.copy()
+        del db["password"]
+        conn = self.connect()
+        c = conn.cursor()
+        c.execute("select ed25519_password(''), ed25519_password('ed25519_password')")
+        for r in c:
+            empty_pass = r[0].decode("ascii")
+            non_empty_pass = r[1].decode("ascii")
+
+        with TempUser(
+            c,
+            "pymysql_ed25519",
+            self.databases[0]["database"],
+            "ed25519",
+            empty_pass,
+        ):
+            pymysql.connect(user="pymysql_ed25519", password="", **db)
+
+        with TempUser(
+            c,
+            "pymysql_ed25519",
+            self.databases[0]["database"],
+            "ed25519",
+            non_empty_pass,
+        ):
+            pymysql.connect(user="pymysql_ed25519", password="ed25519_password", **db)
+
 
 class TestConnection(base.PyMySQLTestCase):
     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)"""
@@ -468,7 +506,7 @@ def test_connection_gone_away(self):
         time.sleep(2)
         with self.assertRaises(pymysql.OperationalError) as cm:
             cur.execute("SELECT 1+1")
-        # error occures while reading, not writing because of socket buffer.
+        # error occurs while reading, not writing because of socket buffer.
         # self.assertEqual(cm.exception.args[0], 2006)
         self.assertIn(cm.exception.args[0], (2006, 2013))
 
@@ -520,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:
@@ -534,17 +570,43 @@ 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.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",
+                },
+                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=None,
+            )
+            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:
@@ -553,23 +615,28 @@ def test_ssl_connect(self):
                     "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")
+            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)
+        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
@@ -577,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:
@@ -588,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:
@@ -607,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:
@@ -628,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:
@@ -651,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
@@ -658,14 +729,36 @@ 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.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_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=None,
+            )
+            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:
@@ -673,18 +766,22 @@ def test_ssl_connect(self):
                 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")
+            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)
+        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:
@@ -695,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:
@@ -710,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
 
@@ -739,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
@@ -763,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
@@ -801,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 b4ced4b06..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"]
@@ -14,7 +12,7 @@
 
 class TestOldIssues(base.PyMySQLTestCase):
     def test_issue_3(self):
-        """ undefined methods datetime_or_None, date_or_None """
+        """undefined methods datetime_or_None, date_or_None"""
         conn = self.connect()
         c = conn.cursor()
         with warnings.catch_warnings():
@@ -42,7 +40,7 @@ def test_issue_3(self):
             c.execute("drop table issue3")
 
     def test_issue_4(self):
-        """ can't retrieve TIMESTAMP fields """
+        """can't retrieve TIMESTAMP fields"""
         conn = self.connect()
         c = conn.cursor()
         with warnings.catch_warnings():
@@ -57,13 +55,13 @@ def test_issue_4(self):
             c.execute("drop table issue4")
 
     def test_issue_5(self):
-        """ query on information_schema.tables fails """
+        """query on information_schema.tables fails"""
         con = self.connect()
         cur = con.cursor()
         cur.execute("select * from information_schema.tables")
 
     def test_issue_6(self):
-        """ exception: TypeError: ord() expected a character, but string of length 0 found """
+        """exception: TypeError: ord() expected a character, but string of length 0 found"""
         # ToDo: this test requires access to db 'mysql'.
         kwargs = self.databases[0].copy()
         kwargs["database"] = "mysql"
@@ -73,7 +71,7 @@ def test_issue_6(self):
         conn.close()
 
     def test_issue_8(self):
-        """ Primary Key and Index error when selecting data """
+        """Primary Key and Index error when selecting data"""
         conn = self.connect()
         c = conn.cursor()
         with warnings.catch_warnings():
@@ -93,7 +91,7 @@ def test_issue_8(self):
             c.execute("drop table test")
 
     def test_issue_13(self):
-        """ can't handle large result fields """
+        """can't handle large result fields"""
         conn = self.connect()
         cur = conn.cursor()
         with warnings.catch_warnings():
@@ -112,7 +110,7 @@ def test_issue_13(self):
             cur.execute("drop table issue13")
 
     def test_issue_15(self):
-        """ query should be expanded before perform character encoding """
+        """query should be expanded before perform character encoding"""
         conn = self.connect()
         c = conn.cursor()
         with warnings.catch_warnings():
@@ -127,7 +125,7 @@ def test_issue_15(self):
             c.execute("drop table issue15")
 
     def test_issue_16(self):
-        """ Patch for string and tuple escaping """
+        """Patch for string and tuple escaping"""
         conn = self.connect()
         c = conn.cursor()
         with warnings.catch_warnings():
@@ -149,7 +147,7 @@ def test_issue_16(self):
         "test_issue_17() requires a custom, legacy MySQL configuration and will not be run."
     )
     def test_issue_17(self):
-        """could not connect mysql use passwod"""
+        """could not connect mysql use password"""
         conn = self.connect()
         host = self.databases[0]["host"]
         db = self.databases[0]["database"]
@@ -285,7 +283,7 @@ def disabled_test_issue_54(self):
 
 class TestGitHubIssues(base.PyMySQLTestCase):
     def test_issue_66(self):
-        """ 'Connection' object has no attribute 'insert_id' """
+        """'Connection' object has no attribute 'insert_id'"""
         conn = self.connect()
         c = conn.cursor()
         self.assertEqual(0, conn.insert_id())
@@ -303,7 +301,7 @@ def test_issue_66(self):
             c.execute("drop table issue66")
 
     def test_issue_79(self):
-        """ Duplicate field overwrites the previous one in the result of DictCursor """
+        """Duplicate field overwrites the previous one in the result of DictCursor"""
         conn = self.connect()
         c = conn.cursor(pymysql.cursors.DictCursor)
 
@@ -330,7 +328,7 @@ def test_issue_79(self):
             c.execute("drop table b")
 
     def test_issue_95(self):
-        """ Leftover trailing OK packet for "CALL my_sp" queries """
+        """Leftover trailing OK packet for "CALL my_sp" queries"""
         conn = self.connect()
         cur = conn.cursor()
         with warnings.catch_warnings():
@@ -352,7 +350,7 @@ def test_issue_95(self):
                 cur.execute("DROP PROCEDURE IF EXISTS `foo`")
 
     def test_issue_114(self):
-        """ autocommit is not set after reconnecting with ping() """
+        """autocommit is not set after reconnecting with ping()"""
         conn = pymysql.connect(charset="utf8", **self.databases[0])
         conn.autocommit(False)
         c = conn.cursor()
@@ -377,12 +375,12 @@ def test_issue_114(self):
         conn.close()
 
     def test_issue_175(self):
-        """ The number of fields returned by server is read in wrong way """
+        """The number of fields returned by server is read in wrong way"""
         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")
@@ -393,7 +391,7 @@ def test_issue_175(self):
                     cur.execute("drop table if exists test_field_count")
 
     def test_issue_321(self):
-        """ Test iterable as query argument. """
+        """Test iterable as query argument."""
         conn = pymysql.connect(charset="utf8", **self.databases[0])
         self.safe_create_table(
             conn,
@@ -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"],
@@ -422,7 +419,7 @@ def test_issue_321(self):
         self.assertEqual(cur.fetchone(), ("c", "\u0430"))
 
     def test_issue_364(self):
-        """ Test mixed unicode/binary arguments in executemany. """
+        """Test mixed unicode/binary arguments in executemany."""
         conn = pymysql.connect(charset="utf8mb4", **self.databases[0])
         self.safe_create_table(
             conn,
@@ -454,7 +451,7 @@ def test_issue_364(self):
         cur.executemany(usql, args=(values, values, values))
 
     def test_issue_363(self):
-        """ Test binary / geometry types. """
+        """Test binary / geometry types."""
         conn = pymysql.connect(charset="utf8", **self.databases[0])
         self.safe_create_table(
             conn,
@@ -466,29 +463,20 @@ def test_issue_363(self):
         )
 
         cur = conn.cursor()
-        # From MySQL 5.7, ST_GeomFromText is added and GeomFromText is deprecated.
-        if self.mysql_server_is(conn, (5, 7, 0)):
-            geom_from_text = "ST_GeomFromText"
-            geom_as_text = "ST_AsText"
-            geom_as_bin = "ST_AsBinary"
-        else:
-            geom_from_text = "GeomFromText"
-            geom_as_text = "AsText"
-            geom_as_bin = "AsBinary"
         query = (
             "INSERT INTO issue363 (id, geom) VALUES"
-            "(1998, %s('LINESTRING(1.1 1.1,2.2 2.2)'))" % geom_from_text
+            "(1998, ST_GeomFromText('LINESTRING(1.1 1.1,2.2 2.2)'))"
         )
         cur.execute(query)
 
         # select WKT
-        query = "SELECT %s(geom) FROM issue363" % geom_as_text
+        query = "SELECT ST_AsText(geom) FROM issue363"
         cur.execute(query)
         row = cur.fetchone()
         self.assertEqual(row, ("LINESTRING(1.1 1.1,2.2 2.2)",))
 
         # select WKB
-        query = "SELECT %s(geom) FROM issue363" % geom_as_bin
+        query = "SELECT ST_AsBinary(geom) FROM issue363"
         cur.execute(query)
         row = cur.fetchone()
         self.assertEqual(
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 ffead0caf..bb47cc5f6 100644
--- a/pymysql/tests/thirdparty/test_MySQLdb/capabilities.py
+++ b/pymysql/tests/thirdparty/test_MySQLdb/capabilities.py
@@ -4,13 +4,11 @@
     Adapted from a script by M-A Lemburg.
 
 """
-import sys
 from time import time
 import unittest
 
 
 class DatabaseTest(unittest.TestCase):
-
     db_module = None
     connect_args = ()
     connect_kwargs = dict(use_unicode=True, charset="utf8mb4", binary_prefix=True)
diff --git a/pymysql/tests/thirdparty/test_MySQLdb/dbapi20.py b/pymysql/tests/thirdparty/test_MySQLdb/dbapi20.py
index 6766aff32..fff14b86f 100644
--- a/pymysql/tests/thirdparty/test_MySQLdb/dbapi20.py
+++ b/pymysql/tests/thirdparty/test_MySQLdb/dbapi20.py
@@ -51,9 +51,9 @@
 # - Now a subclass of TestCase, to avoid requiring the driver stub
 #   to use multiple inheritance
 # - Reversed the polarity of buggy test in test_description
-# - Test exception heirarchy correctly
+# - Test exception hierarchy correctly
 # - self.populate is now self._populate(), so if a driver stub
-#   overrides self.ddl1 this change propogates
+#   overrides self.ddl1 this change propagates
 # - VARCHAR columns now have a width, which will hopefully make the
 #   DDL even more portible (this will be reversed if it causes more problems)
 # - cursor.rowcount being checked after various execute and fetchXXX methods
@@ -174,7 +174,7 @@ def test_paramstyle(self):
 
     def test_Exceptions(self):
         # Make sure required exceptions exist, and are in the
-        # defined heirarchy.
+        # defined hierarchy.
         self.assertTrue(issubclass(self.driver.Warning, Exception))
         self.assertTrue(issubclass(self.driver.Error, Exception))
         self.assertTrue(issubclass(self.driver.InterfaceError, self.driver.Error))
@@ -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):
@@ -474,7 +474,7 @@ def test_fetchone(self):
             self.assertRaises(self.driver.Error, cur.fetchone)
 
             # cursor.fetchone should raise an Error if called after
-            # executing a query that cannnot return rows
+            # executing a query that cannot return rows
             self.executeDDL1(cur)
             self.assertRaises(self.driver.Error, cur.fetchone)
 
@@ -482,12 +482,12 @@ 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))
 
             # cursor.fetchone should raise an Error if called after
-            # executing a query that cannnot return rows
+            # executing a query that cannot return rows
             cur.execute(
                 "insert into %sbooze values ('Victoria Bitter')" % (self.table_prefix)
             )
@@ -792,7 +792,7 @@ def test_setoutputsize_basic(self):
             con.close()
 
     def test_setoutputsize(self):
-        # Real test for setoutputsize is driver dependant
+        # Real test for setoutputsize is driver dependent
         raise NotImplementedError("Driver need to override this test")
 
     def test_None(self):
@@ -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 139089ab1..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
@@ -8,7 +7,6 @@
 
 
 class test_MySQLdb(capabilities.DatabaseTest):
-
     db_module = pymysql
     connect_args = ()
     connect_kwargs = base.PyMySQLTestCase.databases[0].copy()
diff --git a/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py b/pymysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py
index e882c5eb3..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
@@ -23,9 +21,6 @@ def test_setoutputsize(self):
     def test_setoutputsize_basic(self):
         pass
 
-    def test_nextset(self):
-        pass
-
     """The tests on fetchone and fetchall and rowcount bogusly
     test for an exception if the statement cannot return a
     result set. MySQL always returns a result set; it's just that
@@ -95,7 +90,7 @@ def test_fetchone(self):
             self.assertRaises(self.driver.Error, cur.fetchone)
 
             # cursor.fetchone should raise an Error if called after
-            # executing a query that cannnot return rows
+            # executing a query that cannot return rows
             self.executeDDL1(cur)
             ##             self.assertRaises(self.driver.Error,cur.fetchone)
 
@@ -103,12 +98,12 @@ 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))
 
             # cursor.fetchone should raise an Error if called after
-            # executing a query that cannnot return rows
+            # executing a query that cannot return rows
             cur.execute(
                 "insert into %sbooze values ('Victoria Bitter')" % (self.table_prefix)
             )
@@ -184,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
new file mode 100644
index 000000000..ee103916f
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,67 @@
+[project]
+name = "PyMySQL"
+description = "Pure Python MySQL Driver"
+authors = [
+    {name = "Inada Naoki", email = "songofacandy@gmail.com"},
+    {name = "Yutaka Matsubara", email = "yutaka.matsubara@gmail.com"}
+]
+dependencies = []
+
+requires-python = ">=3.7"
+readme = "README.md"
+license = {text = "MIT License"}
+keywords = ["MySQL"]
+classifiers = [
+    "Development Status :: 5 - Production/Stable",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.7",
+    "Programming Language :: Python :: 3.8",
+    "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",
+    "License :: OSI Approved :: MIT License",
+    "Topic :: Database",
+]
+dynamic = ["version"]
+
+[project.optional-dependencies]
+"rsa" = [
+    "cryptography"
+]
+"ed25519" = [
+    "PyNaCl>=1.4.0"
+]
+
+[project.urls]
+"Project" = "https://github.com/PyMySQL/PyMySQL"
+"Documentation" = "https://pymysql.readthedocs.io/"
+
+[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+namespaces = false
+include = ["pymysql*"]
+exclude = ["tests*", "pymysql.tests*"]
+
+[tool.setuptools.dynamic]
+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 d65512fbb..140d37067 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,3 +1,4 @@
 cryptography
 PyNaCl>=1.4.0
 pytest
+pytest-cov
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index b40802e4b..000000000
--- a/setup.cfg
+++ /dev/null
@@ -1,13 +0,0 @@
-[flake8]
-ignore = E203,E501,W503,E722
-exclude = tests,build,.venv,docs
-
-[metadata]
-license = "MIT"
-license_files = LICENSE
-
-author=yutaka.matsubara
-author_email=yutaka.matsubara@gmail.com
-
-maintainer=Inada Naoki
-maintainer_email=songofacandy@gmail.com
diff --git a/setup.py b/setup.py
deleted file mode 100755
index 1510a0cf8..000000000
--- a/setup.py
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/usr/bin/env python
-from setuptools import setup, find_packages
-
-version = "1.0.2"
-
-with open("./README.rst", encoding="utf-8") as f:
-    readme = f.read()
-
-setup(
-    name="PyMySQL",
-    version=version,
-    url="https://github.com/PyMySQL/PyMySQL/",
-    project_urls={
-        "Documentation": "https://pymysql.readthedocs.io/",
-    },
-    description="Pure Python MySQL Driver",
-    long_description=readme,
-    packages=find_packages(exclude=["tests*", "pymysql.tests*"]),
-    python_requires=">=3.6",
-    extras_require={
-        "rsa": ["cryptography"],
-        "ed25519": ["PyNaCl>=1.4.0"],
-    },
-    classifiers=[
-        "Development Status :: 5 - Production/Stable",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
-        "Programming Language :: Python :: 3.9",
-        "Programming Language :: Python :: Implementation :: CPython",
-        "Programming Language :: Python :: Implementation :: PyPy",
-        "Intended Audience :: Developers",
-        "License :: OSI Approved :: MIT License",
-        "Topic :: Database",
-    ],
-    keywords="MySQL",
-)
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
diff --git a/tests/test_mariadb_auth.py b/tests/test_mariadb_auth.py
deleted file mode 100644
index b3a2719cd..000000000
--- a/tests/test_mariadb_auth.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Test for auth methods supported by MariaDB 10.3+"""
-
-import pymysql
-
-# pymysql.connections.DEBUG = True
-# pymysql._auth.DEBUG = True
-
-host = "127.0.0.1"
-port = 3306
-
-
-def test_ed25519_no_password():
-    con = pymysql.connect(user="nopass_ed25519", host=host, port=port, ssl=None)
-    con.close()
-
-
-def test_ed25519_password():  # nosec
-    con = pymysql.connect(
-        user="user_ed25519", password="pass_ed25519", host=host, port=port, ssl=None
-    )
-    con.close()
-
-
-# default mariadb docker images aren't configured with SSL