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 @@
+[](https://pymysql.readthedocs.io/)
+[](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:
+
+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:
+- MySQL Reference Manuals:
+- Getting Help With MariaDB
+- MySQL client/server protocol:
+
+- "Connector" channel in MySQL Community Slack:
+
+- PyMySQL mailing list:
+
+
+## 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 `_.
-
-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
-`_.
-
-
-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 ^` where ^ is one of
- echo. html to make standalone HTML files
- echo. dirhtml to make HTML files named index.html in directories
- echo. singlehtml to make a single large HTML file
- echo. pickle to make pickle files
- echo. json to make JSON files
- echo. htmlhelp to make HTML files and a HTML help project
- echo. qthelp to make HTML files and a qthelp project
- echo. devhelp to make HTML files and a Devhelp project
- echo. epub to make an epub
- echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
- echo. text to make text files
- echo. man to make manual pages
- echo. texinfo to make Texinfo files
- echo. gettext to make PO message catalogs
- echo. changes to make an overview over all changed/added/deprecated items
- echo. xml to make Docutils-native XML files
- echo. pseudoxml to make pseudoxml-XML files for display purposes
- echo. linkcheck to check all external links for integrity
- echo. doctest to run all doctests embedded in the documentation if enabled
- goto end
-)
-
-if "%1" == "clean" (
- for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
- del /q /s %BUILDDIR%\*
- goto end
-)
-
-
-%SPHINXBUILD% 2> nul
-if errorlevel 9009 (
- echo.
- echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
- echo.installed, then set the SPHINXBUILD environment variable to point
- echo.to the full path of the 'sphinx-build' executable. Alternatively you
- echo.may add the Sphinx directory to PATH.
- echo.
- echo.If you don't have Sphinx installed, grab it from
- echo.http://sphinx-doc.org/
- exit /b 1
-)
-
-if "%1" == "html" (
- %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The HTML pages are in %BUILDDIR%/html.
- goto end
-)
-
-if "%1" == "dirhtml" (
- %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
- goto end
-)
-
-if "%1" == "singlehtml" (
- %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
- goto end
-)
-
-if "%1" == "pickle" (
- %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished; now you can process the pickle files.
- goto end
-)
-
-if "%1" == "json" (
- %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished; now you can process the JSON files.
- goto end
-)
-
-if "%1" == "htmlhelp" (
- %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished; now you can run HTML Help Workshop with the ^
-.hhp project file in %BUILDDIR%/htmlhelp.
- goto end
-)
-
-if "%1" == "qthelp" (
- %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished; now you can run "qcollectiongenerator" with the ^
-.qhcp project file in %BUILDDIR%/qthelp, like this:
- echo.^> qcollectiongenerator %BUILDDIR%\qthelp\PyMySQL.qhcp
- echo.To view the help file:
- echo.^> assistant -collectionFile %BUILDDIR%\qthelp\PyMySQL.ghc
- goto end
-)
-
-if "%1" == "devhelp" (
- %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished.
- goto end
-)
-
-if "%1" == "epub" (
- %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The epub file is in %BUILDDIR%/epub.
- goto end
-)
-
-if "%1" == "latex" (
- %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
- goto end
-)
-
-if "%1" == "latexpdf" (
- %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
- cd %BUILDDIR%/latex
- make all-pdf
- cd %BUILDDIR%/..
- echo.
- echo.Build finished; the PDF files are in %BUILDDIR%/latex.
- goto end
-)
-
-if "%1" == "latexpdfja" (
- %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
- cd %BUILDDIR%/latex
- make all-pdf-ja
- cd %BUILDDIR%/..
- echo.
- echo.Build finished; the PDF files are in %BUILDDIR%/latex.
- goto end
-)
-
-if "%1" == "text" (
- %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The text files are in %BUILDDIR%/text.
- goto end
-)
-
-if "%1" == "man" (
- %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The manual pages are in %BUILDDIR%/man.
- goto end
-)
-
-if "%1" == "texinfo" (
- %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
- goto end
-)
-
-if "%1" == "gettext" (
- %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
- goto end
-)
-
-if "%1" == "changes" (
- %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
- if errorlevel 1 exit /b 1
- echo.
- echo.The overview file is in %BUILDDIR%/changes.
- goto end
-)
-
-if "%1" == "linkcheck" (
- %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
- if errorlevel 1 exit /b 1
- echo.
- echo.Link check complete; look for any errors in the above output ^
-or in %BUILDDIR%/linkcheck/output.txt.
- goto end
-)
-
-if "%1" == "doctest" (
- %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
- if errorlevel 1 exit /b 1
- echo.
- echo.Testing of doctests in the sources finished, look at the ^
-results in %BUILDDIR%/doctest/output.txt.
- goto end
-)
-
-if "%1" == "xml" (
- %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The XML files are in %BUILDDIR%/xml.
- goto end
-)
-
-if "%1" == "pseudoxml" (
- %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
- if errorlevel 1 exit /b 1
- echo.
- echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
- goto end
-)
-
-:end
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 000000000..48319f033
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,2 @@
+sphinx~=8.0
+sphinx-rtd-theme~=3.0.0
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 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(">> 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(" 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