diff --git a/.github/DISCUSSION_TEMPLATE/build-issue.yml b/.github/DISCUSSION_TEMPLATE/build-issue.yml new file mode 100644 index 00000000..c2e93df4 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/build-issue.yml @@ -0,0 +1,49 @@ +title: "[Build] " +body: + - type: input + id: os + attributes: + label: What OS and which version do you use? + description: | + e.g. + - Windows 11 + - macOS 13.4 + - Ubuntu 22.04 + + - type: textarea + id: libmysqlclient + attributes: + label: How did you installed mysql client library? + description: | + e.g. + - `apt-get install libmysqlclient-dev` + - `brew install mysql-client` + - `brew install mysql` + render: bash + + - type: textarea + id: pkgconfig-output + attributes: + label: Output from `pkg-config --cflags --libs mysqlclient` + description: If you are using mariadbclient, run `pkg-config --cflags --libs mariadb` instead. + render: bash + + - type: input + id: mysqlclient-install + attributes: + label: How did you tried to install mysqlclient? + description: | + e.g. + - `pip install mysqlclient` + - `poetry add mysqlclient` + + - type: textarea + id: mysqlclient-error + attributes: + label: Output of building mysqlclient + description: not only error message. full log from start installing mysqlclient. + render: bash + + + + diff --git a/.github/DISCUSSION_TEMPLATE/issue-report.yml b/.github/DISCUSSION_TEMPLATE/issue-report.yml new file mode 100644 index 00000000..6724fcf8 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/issue-report.yml @@ -0,0 +1,93 @@ +body: + - type: markdown + attributes: + value: | + Failed to buid? [Use this form](https://github.com/PyMySQL/mysqlclient/discussions/new?category=build-issue). + + We don't use this issue tracker to help users. + Please use this tracker only when you are sure about it is an issue of this software. + + If you had trouble, please ask it on some user community. + + - [Python Discord](https://www.pythondiscord.com/) + For general Python questions, including developing application using MySQL. + + - [MySQL Community Slack](https://lefred.be/mysql-community-on-slack/) + For general MySQL questions. + + - [mysqlclient Discuss](https://github.com/PyMySQL/mysqlclient/discussions) + For mysqlclient specific topics. + + - type: textarea + id: describe + attributes: + label: Describe the bug + description: "A **clear and concise** description of what the bug is." + + - type: textarea + id: environments + attributes: + label: Environment + description: | + - Server and version (e.g. MySQL 8.0.33, MariaDB 10.11.4) + - OS (e.g. Windows 11, Ubuntu 22.04, macOS 13.4.1) + - Python version + + - type: input + id: libmysqlclient + attributes: + label: How did you install libmysqlclient libraries? + description: | + e.g. brew install mysql-cleint, brew install mariadb, apt-get install libmysqlclient-dev + + - type: input + id: mysqlclient-version + attributes: + label: What version of mysqlclient do you use? + + - type: markdown + attributes: + value: | + ## Complete step to reproduce. + # + Do not expect maintainer complement any piece of code, schema, and data need to reproduce. + You need to provide **COMPLETE** step to reproduce. + + It is very recommended to use Docker to start MySQL server. + Maintainer can not use your Database to reproduce your issue. + + **If you write only little code snippet, maintainer may close your issue + without any comment.** + + - type: textarea + id: reproduce-docker + attributes: + label: Docker command to start MySQL server + render: bash + description: e.g. `docker run -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -p 3306:3306 --rm --name mysql mysql:8.0` + + - type: textarea + id: reproduce-code + attributes: + label: Minimum but complete code to reproduce + render: python + value: | + # Write Python code here. + import MySQLdb + + conn = MySQLdb.connect(host='127.0.0.1', port=3306, user='root') + ... + + - type: textarea + id: reproduce-schema + attributes: + label: Schema and initial data required to reproduce. + render: sql + value: | + -- Write SQL here. + -- e.g. CREATE TABLE ... + + - type: textarea + id: reproduce-other + attributes: + label: Commands, and any other step required to reproduce your issue. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 50d2bdc2..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -name: Bug report -about: Report an issue of this project -title: '' -labels: '' -assignees: '' - ---- - -### Read this first - -We don't use this issue tracker to help users. If you had trouble, please ask it on some user community. See [here](https://github.com/PyMySQL/mysqlclient-python#support). -Please use this tracker only when you are sure about it is an issue of this software. - -And please provide full information from first. I don't want to ask questions like "What is your Python version?", "Do you confirm MySQL error log?". If the issue report looks incomplete, I will just close it. - -Are you ready? Please remove until here and make a good issue report!! - - -### Describe the bug - -A clear and concise description of what the bug is. - -### To Reproduce - -**Schema** - -``` -create table .... -``` - -**Code** - -```py -con = MySQLdb.connect(...) -cur = con.cursor() -cur.execute(...) -``` - -### Environment - -**MySQL Server** - -- Server OS (e.g. Windows 10, Ubuntu 20.04): -- Server Version (e.g. MariaDB 10.3.16): - -**MySQL Client** - -- OS (e.g. Windows 10, Ubuntu 20.04): - -- Python (e.g. Homebrew Python 3.7.5): - -- Connector/C (e.g. Homebrew mysql-client 8.0.18): - - -### Additional context - -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..9f1273ed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,12 @@ +contact_links: + - name: Failed to build + about: Ask help for build error. + url: "https://github.com/PyMySQL/mysqlclient/discussions/new?category=build-issue" + + - name: Report issue + about: Found bug? + url: "https://github.com/PyMySQL/mysqlclient/discussions/new?category=issue-report" + + - name: Ask question + about: Ask other questions. + url: "https://github.com/PyMySQL/mysqlclient/discussions/new?category=q-a" diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index d6aff95a..95a95ac3 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,17 +1,15 @@ name: Lint -on: [push, pull_request] +on: + push: + branches: ["main"] + pull_request: 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 - - name: Setup flake8 annotations - uses: rbialon/flake8-annotations@v1 - - name: flake8 - run: | - pip install flake8 - flake8 --ignore=E203,E501,W503 --max-line-length=88 . + - uses: actions/checkout@v4 + - run: pipx install ruff + - run: ruff check src/ + - run: ruff format src/ diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e3c0fec1..5545b885 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -2,48 +2,109 @@ name: Test on: push: + branches: ["main"] pull_request: jobs: - build: - runs-on: ubuntu-20.04 + test: + runs-on: ubuntu-latest + env: + PIP_NO_PYTHON_VERSION_WARNING: 1 + PIP_DISABLE_PIP_VERSION_CHECK: 1 strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + include: + - python-version: "3.11" + mariadb: 1 steps: - - name: Start MySQL + - if: ${{ matrix.mariadb }} + name: Start MariaDB + # https://github.com/actions/runner-images/blob/9d9b3a110dfc98100cdd09cb2c957b9a768e2979/images/linux/scripts/installers/mysql.sh#L10-L13 + run: | + docker pull mariadb:10.11 + docker run -d -e MARIADB_ROOT_PASSWORD=root -p 3306:3306 --rm --name mariadb mariadb:10.11 + sudo apt-get -y install libmariadb-dev + mysql --version + mysql -uroot -proot -h127.0.0.1 -e "CREATE DATABASE mysqldb_test" + + - if: ${{ !matrix.mariadb }} + name: Start MySQL run: | sudo systemctl start mysql.service + mysql --version mysql -uroot -proot -e "CREATE DATABASE mysqldb_test" - - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-1 - restore-keys: | - ${{ runner.os }}-pip- - - - uses: actions/checkout@v2 - with: - fetch-depth: 2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: "pip" + cache-dependency-path: "requirements.txt" + allow-prereleases: true - - name: Install dependencies - env: - PIP_NO_PYTHON_VERSION_WARNING: 1 - PIP_DISABLE_PIP_VERSION_CHECK: 1 + - name: Install mysqlclient run: | - pip install -U coverage pytest pytest-cov - python setup.py develop + pip install -v . + - name: Install test dependencies + run: | + pip install -r requirements.txt + - name: Run tests env: TESTDB: actions.cnf run: | pytest --cov=MySQLdb tests - - uses: codecov/codecov-action@v1 + - uses: codecov/codecov-action@v4 + + django-test: + name: "Run Django LTS test suite" + needs: test + runs-on: ubuntu-latest + env: + PIP_NO_PYTHON_VERSION_WARNING: 1 + PIP_DISABLE_PIP_VERSION_CHECK: 1 + DJANGO_VERSION: "3.2.19" + 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: + # Django 3.2.9+ supports Python 3.10 + # https://docs.djangoproject.com/ja/3.2/releases/3.2/ + python-version: "3.10" + cache: "pip" + cache-dependency-path: "ci/django-requirements.txt" + + - name: Install mysqlclient + run: | + #pip install -r requirements.txt + #pip install mysqlclient # Use stable version + pip install . + + - name: Setup Django + run: | + sudo apt-get install libmemcached-dev + wget https://github.com/django/django/archive/${DJANGO_VERSION}.tar.gz + tar xf ${DJANGO_VERSION}.tar.gz + cp ci/test_mysql.py django-${DJANGO_VERSION}/tests/ + cd django-${DJANGO_VERSION} + pip install . -r tests/requirements/py3.txt + + - name: Run Django test + run: | + cd django-${DJANGO_VERSION}/tests/ + PYTHONPATH=.. python3 ./runtests.py --settings=test_mysql diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml index ac9b28da..ec79ca4c 100644 --- a/.github/workflows/windows.yaml +++ b/.github/workflows/windows.yaml @@ -2,20 +2,20 @@ name: Build windows wheels on: push: - branches: - - master + branches: ["main", "ci"] + pull_request: workflow_dispatch: jobs: build: runs-on: windows-latest env: - CONNECTOR_VERSION: "3.2.4" + CONNECTOR_VERSION: "3.3.8" steps: - name: Cache Connector id: cache-connector - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: c:/mariadb-connector key: mariadb-connector-c-${{ env.CONNECTOR_VERSION }}-win @@ -40,7 +40,7 @@ jobs: cmake -DCMAKE_INSTALL_PREFIX=c:/mariadb-connector -DCMAKE_INSTALL_COMPONENT=Development -DCMAKE_BUILD_TYPE=Release -P cmake_install.cmake - name: Checkout mysqlclient - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: path: mysqlclient @@ -57,36 +57,25 @@ jobs: EOF cat site.cfg + - uses: actions/setup-python@v5 + - name: Install cibuildwheel + run: python -m pip install cibuildwheel - name: Build wheels - shell: cmd + working-directory: mysqlclient + env: + CIBW_PROJECT_REQUIRES_PYTHON: ">=3.8" + CIBW_ARCHS: "AMD64" + CIBW_TEST_COMMAND: "python -c \"import MySQLdb; print(MySQLdb.version_info)\" " + run: "python -m cibuildwheel --prerelease-pythons --output-dir dist" + + - name: Build sdist working-directory: mysqlclient run: | - py -3.10 -m pip install -U setuptools wheel pip - py -3.10 setup.py bdist_wheel - py -3.9 -m pip install -U setuptools wheel pip - py -3.9 setup.py bdist_wheel - py -3.8 -m pip install -U setuptools wheel pip - py -3.8 setup.py bdist_wheel - py -3.7 -m pip install -U setuptools wheel pip - py -3.7 setup.py bdist_wheel + python -m pip install build + python -m build -s -o dist - name: Upload Wheel - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: win-wheels - path: mysqlclient/dist/*.whl - - - name: Check wheels - shell: bash - working-directory: mysqlclient/dist - run: | - ls -la - py -3.10 -m pip install --no-index --find-links . mysqlclient - py -3.10 -c "import MySQLdb; print(MySQLdb.version_info)" - py -3.9 -m pip install --no-index --find-links . mysqlclient - py -3.9 -c "import MySQLdb; print(MySQLdb.version_info)" - py -3.8 -m pip install --no-index --find-links . mysqlclient - py -3.8 -c "import MySQLdb; print(MySQLdb.version_info)" - py -3.7 -m pip install --no-index --find-links . mysqlclient - py -3.7 -c "import MySQLdb; print(MySQLdb.version_info)" - + path: mysqlclient/dist/*.* diff --git a/.gitignore b/.gitignore index 42bbfb5d..1f081cc1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,4 @@ .tox/ build/ dist/ -MySQLdb/release.py .coverage diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..0b288620 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,15 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + + apt_packages: + - default-libmysqlclient-dev + - build-essential + +python: + install: + - requirements: doc/requirements.txt + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 75c6d425..00000000 --- a/.travis.yml +++ /dev/null @@ -1,63 +0,0 @@ -dist: bionic -language: python - -# See aws s3 ls s3://travis-python-archives/binaries/ubuntu/18.04/x86_64/ -python: - - "nightly" - - "pypy3" - -cache: pip - -services: - - mysql - -install: - - pip install -U pip - - pip install -U mock coverage pytest pytest-cov codecov - -env: - global: - - TESTDB=travis.cnf - -before_script: - - "mysql --help" - - "mysql --print-defaults" - - "mysql -e 'create database mysqldb_test charset utf8mb4;'" - -script: - - pip install -e . - - pytest --cov ./MySQLdb - -after_success: - - codecov - -jobs: - fast_finish: true - include: - - &django_2_2 - name: "Django 2.2 test" - env: - - DJANGO_VERSION=2.2.7 - python: "3.8" - install: - - pip install -U pip - - wget https://github.com/django/django/archive/${DJANGO_VERSION}.tar.gz - - tar xf ${DJANGO_VERSION}.tar.gz - - pip install -e django-${DJANGO_VERSION}/ - - cp ci/test_mysql.py django-${DJANGO_VERSION}/tests/ - - pip install . - - before_script: - - mysql -e 'create user django identified by "secret"' - - mysql -e 'grant all on *.* to django' - - mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql mysql - - script: - - cd django-${DJANGO_VERSION}/tests/ - - ./runtests.py --parallel=2 --settings=test_mysql - #- &django_3_0 - # <<: *django_2_2 - # name: "Django 3.0 test (Python 3.8)" - # python: "3.8" - -# vim: sw=2 ts=2 sts=2 diff --git a/HISTORY.rst b/HISTORY.rst index 0b39d23a..3dca31cc 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,8 +1,86 @@ +====================== + What's new in 2.2.4 +====================== + +Release: 2024-02-09 + +* Support ``ssl=True`` in ``connect()``. (#700) + This makes better compatibility with PyMySQL and mysqlclient==2.2.1 + with libmariadb. See #698 for detail. + + +====================== + What's new in 2.2.3 +====================== + +Release: 2024-02-04 + +* Fix ``Connection.kill()`` method that broken in 2.2.2. (#689) + + +====================== + What's new in 2.2.2 +====================== + +Release: 2024-02-04 + +* Support building with MySQL 8.3 (#688). +* Deprecate ``db.shutdown()`` and ``db.kill()`` methods in docstring. + This is because ``mysql_shutdown()`` and ``mysql_kill()`` were removed in MySQL 8.3. + They will emit DeprecationWarning in the future but not for now. + + +====================== + What's new in 2.2.1 +====================== + +Release: 2023-12-13 + +* ``Connection.ping()`` avoid using ``MYSQL_OPT_RECONNECT`` option until + ``reconnect=True`` is specified. MySQL 8.0.33 start showing warning + when the option is used. (#664) +* Windows: Update MariaDB Connector/C to 3.3.8. (#665) +* Windows: Build wheels for Python 3.12 (#644) + + +====================== + What's new in 2.2.0 +====================== + +Release: 2023-06-22 + +* Use ``pkg-config`` instead of ``mysql_config`` (#586) +* Raise ProgrammingError on -inf (#557) +* Raise IntegrityError for ER_BAD_NULL. (#579) +* Windows: Use MariaDB Connector/C 3.3.4 (#585) +* Use pkg-config instead of mysql_config (#586) +* Add collation option (#564) +* Drop Python 3.7 support (#593) +* Use pyproject.toml for build (#598) +* Add Cursor.mogrify (#477) +* Partial support of ssl_mode option with mariadbclient (#475) +* Discard remaining results without creating Python objects (#601) +* Fix executemany with binary prefix (#605) + +====================== + What's new in 2.1.1 +====================== + +Release: 2022-06-22 + +* Fix qualname of exception classes. (#522) +* Fix range check in ``MySQLdb._mysql.result.fetch_row()``. Invalid ``how`` argument caused SEGV. (#538) +* Fix docstring of ``_mysql.connect``. (#540) +* Windows: Binary wheels are updated. (#541) + * Use MariaDB Connector/C 3.3.1. + * Use cibuildwheel to build wheels. + * Python 3.8-3.11 + ====================== What's new in 2.1.0 ====================== -Release: 2021-10-19 (rc1) +Release: 2021-11-17 * Add ``multistatement=True`` option. You can disable multi statement. (#500). * Remove unnecessary bytes encoder which is remained for Django 1.11 @@ -94,7 +172,7 @@ Release: 2019-08-09 * ``--static`` build supports ``libmariadbclient.a`` * Try ``mariadb_config`` when ``mysql_config`` is not found -* Fixed warning happend in Python 3.8 (#359) +* Fixed warning happened in Python 3.8 (#359) * Fixed ``from MySQLdb import *``, while I don't recommend it. (#369) * Fixed SEGV ``MySQLdb.escape_string("1")`` when libmariadb is used and no connection is created. (#367) @@ -294,7 +372,7 @@ More tests for date and time columns. (#41) Fix calling .execute() method for closed cursor cause TypeError. (#37) -Improve peformance to parse date. (#43) +Improve performance to parse date. (#43) Support geometry types (#49) diff --git a/MANIFEST.in b/MANIFEST.in index 07563caf..58a996de 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,12 +1,7 @@ recursive-include doc *.rst recursive-include tests *.py include doc/conf.py -include MANIFEST.in include HISTORY.rst include README.md include LICENSE -include metadata.cfg include site.cfg -include setup_common.py -include setup_posix.py -include setup_windows.py diff --git a/Makefile b/Makefile index 783d1919..3f9ff8bb 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: build build: - python3 setup.py build_ext -if + python setup.py build_ext -if .PHONY: doc doc: @@ -10,7 +10,11 @@ doc: .PHONY: clean clean: - python3 setup.py clean find . -name '*.pyc' -delete find . -name '__pycache__' -delete rm -rf build + +.PHONY: check +check: + ruff *.py src ci + black *.py src ci diff --git a/README.md b/README.md index 4dbc54d7..451ce799 100644 --- a/README.md +++ b/README.md @@ -48,19 +48,18 @@ $ pip install mysqlclient Install MySQL and mysqlclient: -``` -# Assume you are activating Python 3 venv -$ brew install mysql +```bash +$ # Assume you are activating Python 3 venv +$ brew install mysql pkg-config $ pip install mysqlclient ``` If you don't want to install MySQL server, you can use mysql-client instead: -``` -# Assume you are activating Python 3 venv -$ brew install mysql-client -$ echo 'export PATH="/usr/local/opt/mysql-client/bin:$PATH"' >> ~/.bash_profile -$ export PATH="/usr/local/opt/mysql-client/bin:$PATH" +```bash +$ # Assume you are activating Python 3 venv +$ brew install mysql-client pkg-config +$ export PKG_CONFIG_PATH="$(brew --prefix)/opt/mysql-client/lib/pkgconfig" $ pip install mysqlclient ``` @@ -72,8 +71,8 @@ support in some user forum. Don't file a issue on the issue tracker.** You may need to install the Python 3 and MySQL development headers and libraries like so: -* `$ sudo apt-get install python3-dev default-libmysqlclient-dev build-essential` # Debian / Ubuntu -* `% sudo yum install python3-devel mysql-devel` # Red Hat / CentOS +* `$ sudo apt-get install python3-dev default-libmysqlclient-dev build-essential pkg-config` # Debian / Ubuntu +* `% sudo yum install python3-devel mysql-devel pkgconfig` # Red Hat / CentOS Then you can install mysqlclient via pip now: @@ -83,13 +82,13 @@ $ pip install mysqlclient ### Customize build (POSIX) -mysqlclient uses `mysql_config` or `mariadb_config` by default for finding +mysqlclient uses `pkg-config --cflags --ldflags mysqlclient` by default for finding compiler/linker flags. You can use `MYSQLCLIENT_CFLAGS` and `MYSQLCLIENT_LDFLAGS` environment variables to customize compiler/linker options. -``` +```bash $ export MYSQLCLIENT_CFLAGS=`pkg-config mysqlclient --cflags` $ export MYSQLCLIENT_LDFLAGS=`pkg-config mysqlclient --libs` $ pip install mysqlclient diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..75f0c541 --- /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. \ No newline at end of file diff --git a/ci/django-requirements.txt b/ci/django-requirements.txt new file mode 100644 index 00000000..83c8a8f2 --- /dev/null +++ b/ci/django-requirements.txt @@ -0,0 +1,24 @@ +# django-3.2.19/tests/requirements/py3.txt +aiosmtpd +asgiref >= 3.3.2 +argon2-cffi >= 16.1.0 +backports.zoneinfo; python_version < '3.9' +bcrypt +docutils +geoip2 +jinja2 >= 2.9.2 +numpy +Pillow >= 6.2.0 +# pylibmc/libmemcached can't be built on Windows. +pylibmc; sys.platform != 'win32' +pymemcache >= 3.4.0 +# RemovedInDjango41Warning. +python-memcached >= 1.59 +pytz +pywatchman; sys.platform != 'win32' +PyYAML +selenium +sqlparse >= 0.2.2 +tblib >= 1.5.0 +tzdata +colorama; sys.platform == 'win32' diff --git a/ci/test_mysql.py b/ci/test_mysql.py index 88a747a6..9417fc9f 100644 --- a/ci/test_mysql.py +++ b/ci/test_mysql.py @@ -16,18 +16,18 @@ "default": { "ENGINE": "django.db.backends.mysql", "NAME": "django_default", - "USER": "django", "HOST": "127.0.0.1", - "PASSWORD": "secret", - "TEST": {"CHARSET": "utf8mb4", "COLLATION": "utf8mb4_general_ci"}, + "USER": "scott", + "PASSWORD": "tiger", + "TEST": {"CHARSET": "utf8mb3", "COLLATION": "utf8mb3_general_ci"}, }, "other": { "ENGINE": "django.db.backends.mysql", "NAME": "django_other", - "USER": "django", "HOST": "127.0.0.1", - "PASSWORD": "secret", - "TEST": {"CHARSET": "utf8mb4", "COLLATION": "utf8mb4_general_ci"}, + "USER": "scott", + "PASSWORD": "tiger", + "TEST": {"CHARSET": "utf8mb3", "COLLATION": "utf8mb3_general_ci"}, }, } @@ -37,3 +37,5 @@ PASSWORD_HASHERS = [ "django.contrib.auth.hashers.MD5PasswordHasher", ] + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/codecov.yml b/codecov.yml index 174a4994..014486d2 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,2 +1,2 @@ ignore: - - "MySQLdb/constants/*" + - "src/MySQLdb/constants/*" diff --git a/doc/MySQLdb.rst b/doc/MySQLdb.rst index 134a40b6..5e6791d5 100644 --- a/doc/MySQLdb.rst +++ b/doc/MySQLdb.rst @@ -9,53 +9,48 @@ MySQLdb Package :undoc-members: :show-inheritance: -:mod:`connections` Module -------------------------- +:mod:`MySQLdb.connections` Module +--------------------------------- .. automodule:: MySQLdb.connections :members: Connection :undoc-members: - :show-inheritance: -:mod:`converters` Module ------------------------- +:mod:`MySQLdb.converters` Module +-------------------------------- .. automodule:: MySQLdb.converters :members: :undoc-members: - :show-inheritance: -:mod:`cursors` Module ---------------------- +:mod:`MySQLdb.cursors` Module +----------------------------- .. automodule:: MySQLdb.cursors - :members: Cursor + :members: :undoc-members: :show-inheritance: -:mod:`times` Module -------------------- +:mod:`MySQLdb.times` Module +--------------------------- .. automodule:: MySQLdb.times :members: :undoc-members: - :show-inheritance: -:mod:`_mysql` Module --------------------- +:mod:`MySQLdb._mysql` Module +---------------------------- .. automodule:: MySQLdb._mysql :members: :undoc-members: - :show-inheritance: -:mod:`_exceptions` Module -------------------------- +:mod:`MySQLdb._exceptions` Module +--------------------------------- .. automodule:: MySQLdb._exceptions :members: :undoc-members: - :show-inheritance: Subpackages diff --git a/doc/conf.py b/doc/conf.py index 5d8cd1a0..e06003ff 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -22,6 +22,13 @@ # -- General configuration ----------------------------------------------------- +nitpick_ignore = [ + ("py:class", "datetime.date"), + ("py:class", "datetime.time"), + ("py:class", "datetime.datetime"), +] + + # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = "1.0" @@ -42,8 +49,8 @@ master_doc = "index" # General information about the project. -project = "MySQLdb" -copyright = "2012, Andy Dustman" +project = "mysqlclient" +copyright = "2023, Inada Naoki" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -93,7 +100,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = "default" +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 00000000..01406623 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,2 @@ +sphinx~=7.2 +sphinx-rtd-theme~=2.0.0 diff --git a/doc/user_guide.rst b/doc/user_guide.rst index a00f292c..8b057e08 100644 --- a/doc/user_guide.rst +++ b/doc/user_guide.rst @@ -14,7 +14,7 @@ database server that provides the Python database API. Installation ------------ -The ``README`` file has complete installation instructions. +The `README `_ file has complete installation instructions. MySQLdb._mysql @@ -348,6 +348,22 @@ connect(parameters...) *This must be a keyword parameter.* + collation + If ``charset`` and ``collation`` are both supplied, the + character set and collation for the current connection + will be set. + + If omitted, empty string, or None, the default collation + for the ``charset`` is implied by the database server. + + To learn more about the quiddities of character sets and + collations, consult the `MySQL docs + `_ + and `MariaDB docs + `_ + + *This must be a keyword parameter.* + sql_mode If present, the session SQL mode will be set to the given string. For more information on sql_mode, see the MySQL @@ -511,7 +527,7 @@ callproc(procname, args) can only be returned with a SELECT statement. Since a stored procedure may return zero or more result sets, it is impossible for MySQLdb to determine if there are result sets to fetch - before the modified parmeters are accessible. + before the modified parameters are accessible. The parameters are stored in the server as @_*procname*_*n*, where *n* is the position of the parameter. I.e., if you diff --git a/metadata.cfg b/metadata.cfg deleted file mode 100644 index 81523713..00000000 --- a/metadata.cfg +++ /dev/null @@ -1,41 +0,0 @@ -[metadata] -version: 2.1.0rc1 -version_info: (2,1,0,'rc',1) -description: Python interface to MySQL -author: Inada Naoki -author_email: songofacandy@gmail.com -license: GPL -platforms: ALL -url: https://github.com/PyMySQL/mysqlclient -classifiers: - Development Status :: 5 - Production/Stable - Environment :: Other Environment - License :: OSI Approved :: GNU General Public License (GPL) - Operating System :: MacOS :: MacOS X - Operating System :: Microsoft :: Windows :: Windows NT/2000 - Operating System :: OS Independent - Operating System :: POSIX - Operating System :: POSIX :: Linux - Operating System :: Unix - Programming Language :: C - Programming Language :: Python - 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 :: 3.10 - Topic :: Database - Topic :: Database :: Database Engines/Servers -py_modules: - MySQLdb._exceptions - MySQLdb.connections - MySQLdb.converters - MySQLdb.cursors - MySQLdb.release - MySQLdb.times - MySQLdb.constants.CLIENT - MySQLdb.constants.CR - MySQLdb.constants.ER - MySQLdb.constants.FIELD_TYPE - MySQLdb.constants.FLAG diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..0ad7ae58 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "mysqlclient" +# version = "2.2.0dev0" +description = "Python interface to MySQL" +readme = "README.md" +requires-python = ">=3.8" +authors = [ + {name = "Inada Naoki", email = "songofacandy@gmail.com"} +] +license = {text = "GNU General Public License v2 (GPLv2)"} +keywords = ["MySQL"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Other Environment", + "License :: OSI Approved :: GNU General Public License (GPL)", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows :: Windows NT/2000", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: POSIX :: Linux", + "Operating System :: Unix", + "Programming Language :: C", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "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", + "Topic :: Database", + "Topic :: Database :: Database Engines/Servers", +] +dynamic = ["version"] + +[project.urls] +Project = "https://github.com/PyMySQL/mysqlclient" +Documentation = "https://mysqlclient.readthedocs.io/" + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +namespaces = false +where = ["src"] +include = ["MySQLdb*"] + +[tool.setuptools.dynamic] +version = {attr = "MySQLdb.release.__version__"} diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..39a2b6e9 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..e2546870 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +# This file is for GitHub Action +coverage +pytest +pytest-cov +tblib diff --git a/setup.py b/setup.py index dfa661c1..f7f3d924 100644 --- a/setup.py +++ b/setup.py @@ -1,22 +1,168 @@ #!/usr/bin/env python - import os +import subprocess +import sys import setuptools +from configparser import ConfigParser + + +release_info = {} +with open("src/MySQLdb/release.py", encoding="utf-8") as f: + exec(f.read(), None, release_info) + + +def find_package_name(): + """Get available pkg-config package name""" + # Ubuntu uses mariadb.pc, but CentOS uses libmariadb.pc + packages = ["mysqlclient", "mariadb", "libmariadb"] + for pkg in packages: + try: + cmd = f"pkg-config --exists {pkg}" + print(f"Trying {cmd}") + subprocess.check_call(cmd, shell=True) + except subprocess.CalledProcessError as err: + print(err) + else: + return pkg + raise Exception( + "Can not find valid pkg-config name.\n" + "Specify MYSQLCLIENT_CFLAGS and MYSQLCLIENT_LDFLAGS env vars manually" + ) + + +def get_config_posix(options=None): + # allow a command-line option to override the base config file to permit + # a static build to be created via requirements.txt + # TODO: find a better way for + static = False + if "--static" in sys.argv: + static = True + sys.argv.remove("--static") + + ldflags = os.environ.get("MYSQLCLIENT_LDFLAGS") + cflags = os.environ.get("MYSQLCLIENT_CFLAGS") + + pkg_name = None + static_opt = " --static" if static else "" + if not (cflags and ldflags): + pkg_name = find_package_name() + if not cflags: + cflags = subprocess.check_output( + f"pkg-config{static_opt} --cflags {pkg_name}", encoding="utf-8", shell=True + ) + if not ldflags: + ldflags = subprocess.check_output( + f"pkg-config{static_opt} --libs {pkg_name}", encoding="utf-8", shell=True + ) + + cflags = cflags.split() + for f in cflags: + if f.startswith("-std="): + break + else: + cflags += ["-std=c99"] + + ldflags = ldflags.split() + + define_macros = [ + ("version_info", release_info["version_info"]), + ("__version__", release_info["__version__"]), + ] + + ext_options = dict( + extra_compile_args=cflags, + extra_link_args=ldflags, + define_macros=define_macros, + ) + # newer versions of gcc require libstdc++ if doing a static build + if static: + ext_options["language"] = "c++" + + return ext_options + + +def get_config_win32(options): + client = "mariadbclient" + connector = os.environ.get("MYSQLCLIENT_CONNECTOR", options.get("connector")) + if not connector: + connector = os.path.join( + os.environ["ProgramFiles"], "MariaDB", "MariaDB Connector C" + ) + + extra_objects = [] + + library_dirs = [ + os.path.join(connector, "lib", "mariadb"), + os.path.join(connector, "lib"), + ] + libraries = [ + "kernel32", + "advapi32", + "wsock32", + "shlwapi", + "Ws2_32", + "crypt32", + "secur32", + "bcrypt", + client, + ] + include_dirs = [ + os.path.join(connector, "include", "mariadb"), + os.path.join(connector, "include"), + ] + + extra_link_args = ["/MANIFEST"] + + define_macros = [ + ("version_info", release_info["version_info"]), + ("__version__", release_info["__version__"]), + ] + + ext_options = dict( + library_dirs=library_dirs, + libraries=libraries, + extra_link_args=extra_link_args, + include_dirs=include_dirs, + extra_objects=extra_objects, + define_macros=define_macros, + ) + return ext_options + + +def enabled(options, option): + value = options[option] + s = value.lower() + if s in ("yes", "true", "1", "y"): + return True + elif s in ("no", "false", "0", "n"): + return False + else: + raise ValueError(f"Unknown value {value} for option {option}") + + +def get_options(): + config = ConfigParser() + config.read(["site.cfg"]) + options = dict(config.items("options")) + options["static"] = enabled(options, "static") + return options + -if os.name == "posix": - from setup_posix import get_config -else: # assume windows - from setup_windows import get_config +if sys.platform == "win32": + ext_options = get_config_win32(get_options()) +else: + ext_options = get_config_posix(get_options()) -with open("README.md", encoding="utf-8") as f: - readme = f.read() +print("# Options for building extension module:") +for k, v in ext_options.items(): + print(f" {k}: {v}") -metadata, options = get_config() -metadata["ext_modules"] = [ - setuptools.Extension("MySQLdb._mysql", sources=["MySQLdb/_mysql.c"], **options) +ext_modules = [ + setuptools.Extension( + "MySQLdb._mysql", + sources=["src/MySQLdb/_mysql.c"], + **ext_options, + ) ] -metadata["long_description"] = readme -metadata["long_description_content_type"] = "text/markdown" -metadata["python_requires"] = ">=3.5" -setuptools.setup(**metadata) +setuptools.setup(ext_modules=ext_modules) diff --git a/setup_common.py b/setup_common.py deleted file mode 100644 index 5b6927ac..00000000 --- a/setup_common.py +++ /dev/null @@ -1,37 +0,0 @@ -from configparser import ConfigParser as SafeConfigParser - - -def get_metadata_and_options(): - config = SafeConfigParser() - config.read(["metadata.cfg", "site.cfg"]) - - metadata = dict(config.items("metadata")) - options = dict(config.items("options")) - - metadata["py_modules"] = list(filter(None, metadata["py_modules"].split("\n"))) - metadata["classifiers"] = list(filter(None, metadata["classifiers"].split("\n"))) - - return metadata, options - - -def enabled(options, option): - value = options[option] - s = value.lower() - if s in ("yes", "true", "1", "y"): - return True - elif s in ("no", "false", "0", "n"): - return False - else: - raise ValueError("Unknown value {} for option {}".format(value, option)) - - -def create_release_file(metadata): - with open("MySQLdb/release.py", "w", encoding="utf-8") as rel: - rel.write( - """ -__author__ = "%(author)s <%(author_email)s>" -version_info = %(version_info)s -__version__ = "%(version)s" -""" - % metadata - ) diff --git a/setup_posix.py b/setup_posix.py deleted file mode 100644 index 99763cbc..00000000 --- a/setup_posix.py +++ /dev/null @@ -1,168 +0,0 @@ -import os -import sys - -# This dequote() business is required for some older versions -# of mysql_config - - -def dequote(s): - if not s: - raise Exception( - "Wrong MySQL configuration: maybe https://bugs.mysql.com/bug.php?id=86971 ?" - ) - if s[0] in "\"'" and s[0] == s[-1]: - s = s[1:-1] - return s - - -_mysql_config_path = "mysql_config" - - -def mysql_config(what): - cmd = "{} --{}".format(_mysql_config_path, what) - print(cmd) - f = os.popen(cmd) - data = f.read().strip().split() - ret = f.close() - if ret: - if ret / 256: - data = [] - if ret / 256 > 1: - raise OSError("{} not found".format(_mysql_config_path)) - print(data) - return data - - -def get_config(): - from setup_common import get_metadata_and_options, enabled, create_release_file - - global _mysql_config_path - - metadata, options = get_metadata_and_options() - - if "mysql_config" in options: - _mysql_config_path = options["mysql_config"] - else: - try: - mysql_config("version") - except OSError: - # try mariadb_config - _mysql_config_path = "mariadb_config" - try: - mysql_config("version") - except OSError: - _mysql_config_path = "mysql_config" - - extra_objects = [] - static = enabled(options, "static") - - # allow a command-line option to override the base config file to permit - # a static build to be created via requirements.txt - # - if "--static" in sys.argv: - static = True - sys.argv.remove("--static") - - libs = os.environ.get("MYSQLCLIENT_LDFLAGS") - if libs: - libs = libs.strip().split() - else: - libs = mysql_config("libs") - library_dirs = [dequote(i[2:]) for i in libs if i.startswith("-L")] - libraries = [dequote(i[2:]) for i in libs if i.startswith("-l")] - extra_link_args = [x for x in libs if not x.startswith(("-l", "-L"))] - - cflags = os.environ.get("MYSQLCLIENT_CFLAGS") - if cflags: - use_mysqlconfig_cflags = False - cflags = cflags.strip().split() - else: - use_mysqlconfig_cflags = True - cflags = mysql_config("cflags") - - include_dirs = [] - extra_compile_args = ["-std=c99"] - - for a in cflags: - if a.startswith("-I"): - include_dirs.append(dequote(a[2:])) - elif a.startswith(("-L", "-l")): # This should be LIBS. - pass - else: - extra_compile_args.append(a.replace("%", "%%")) - - # Copy the arch flags for linking as well - try: - i = extra_compile_args.index("-arch") - if "-arch" not in extra_link_args: - extra_link_args += ["-arch", extra_compile_args[i + 1]] - except ValueError: - pass - - if static: - # properly handle mysql client libraries that are not called libmysqlclient - client = None - CLIENT_LIST = [ - "mysqlclient", - "mysqlclient_r", - "mysqld", - "mariadb", - "mariadbclient", - "perconaserverclient", - "perconaserverclient_r", - ] - for c in CLIENT_LIST: - if c in libraries: - client = c - break - - if client == "mariadb": - client = "mariadbclient" - if client is None: - raise ValueError("Couldn't identify mysql client library") - - extra_objects.append(os.path.join(library_dirs[0], "lib%s.a" % client)) - if client in libraries: - libraries.remove(client) - else: - if use_mysqlconfig_cflags: - # mysql_config may have "-lmysqlclient -lz -lssl -lcrypto", but zlib and - # ssl is not used by _mysql. They are needed only for static build. - for L in ("crypto", "ssl", "z", "zstd"): - if L in libraries: - libraries.remove(L) - - name = "mysqlclient" - metadata["name"] = name - - define_macros = [ - ("version_info", metadata["version_info"]), - ("__version__", metadata["version"]), - ] - create_release_file(metadata) - del metadata["version_info"] - ext_options = dict( - library_dirs=library_dirs, - libraries=libraries, - extra_compile_args=extra_compile_args, - extra_link_args=extra_link_args, - include_dirs=include_dirs, - extra_objects=extra_objects, - define_macros=define_macros, - ) - - # newer versions of gcc require libstdc++ if doing a static build - if static: - ext_options["language"] = "c++" - - print("ext_options:") - for k, v in ext_options.items(): - print(" {}: {}".format(k, v)) - - return metadata, ext_options - - -if __name__ == "__main__": - sys.stderr.write( - """You shouldn't be running this directly; it is used by setup.py.""" - ) diff --git a/setup_windows.py b/setup_windows.py deleted file mode 100644 index b2feb7d2..00000000 --- a/setup_windows.py +++ /dev/null @@ -1,64 +0,0 @@ -import os -import sys - - -def get_config(): - from setup_common import get_metadata_and_options, create_release_file - - metadata, options = get_metadata_and_options() - - client = "mariadbclient" - connector = os.environ.get("MYSQLCLIENT_CONNECTOR", options.get("connector")) - if not connector: - connector = os.path.join( - os.environ["ProgramFiles"], "MariaDB", "MariaDB Connector C" - ) - - extra_objects = [] - - library_dirs = [ - os.path.join(connector, "lib", "mariadb"), - os.path.join(connector, "lib"), - ] - libraries = [ - "kernel32", - "advapi32", - "wsock32", - "shlwapi", - "Ws2_32", - "crypt32", - "secur32", - "bcrypt", - client, - ] - include_dirs = [ - os.path.join(connector, "include", "mariadb"), - os.path.join(connector, "include"), - ] - - extra_link_args = ["/MANIFEST"] - - name = "mysqlclient" - metadata["name"] = name - - define_macros = [ - ("version_info", metadata["version_info"]), - ("__version__", metadata["version"]), - ] - create_release_file(metadata) - del metadata["version_info"] - ext_options = dict( - library_dirs=library_dirs, - libraries=libraries, - extra_link_args=extra_link_args, - include_dirs=include_dirs, - extra_objects=extra_objects, - define_macros=define_macros, - ) - return metadata, ext_options - - -if __name__ == "__main__": - sys.stderr.write( - """You shouldn't be running this directly; it is used by setup.py.""" - ) diff --git a/site.cfg b/site.cfg index 08a14b0e..39e3c2b1 100644 --- a/site.cfg +++ b/site.cfg @@ -2,11 +2,6 @@ # static: link against a static library static = False -# The path to mysql_config. -# Only use this if mysql_config is not on your PATH, or you have some weird -# setup that requires it. -#mysql_config = /usr/local/bin/mysql_config - # http://stackoverflow.com/questions/1972259/mysql-python-install-problem-using-virtualenv-windows-pip # Windows connector libs for MySQL. You need a 32-bit connector for your 32-bit Python build. connector = diff --git a/MySQLdb/__init__.py b/src/MySQLdb/__init__.py similarity index 91% rename from MySQLdb/__init__.py rename to src/MySQLdb/__init__.py index b567363b..153bbdfe 100644 --- a/MySQLdb/__init__.py +++ b/src/MySQLdb/__init__.py @@ -13,16 +13,14 @@ MySQLdb.converters module. """ -try: - from MySQLdb.release import version_info - from . import _mysql +from .release import version_info +from . import _mysql - assert version_info == _mysql.version_info -except Exception: +if version_info != _mysql.version_info: raise ImportError( - "this is MySQLdb version {}, but _mysql is version {!r}\n_mysql: {!r}".format( - version_info, _mysql.version_info, _mysql.__file__ - ) + f"this is MySQLdb version {version_info}, " + f"but _mysql is version {_mysql.version_info!r}\n" + f"_mysql: {_mysql.__file__!r}" ) diff --git a/MySQLdb/_exceptions.py b/src/MySQLdb/_exceptions.py similarity index 87% rename from MySQLdb/_exceptions.py rename to src/MySQLdb/_exceptions.py index ba35deaf..a5aca7e1 100644 --- a/MySQLdb/_exceptions.py +++ b/src/MySQLdb/_exceptions.py @@ -9,32 +9,44 @@ class MySQLError(Exception): """Exception related to operation with MySQL.""" + __module__ = "MySQLdb" + class Warning(Warning, MySQLError): """Exception raised for important warnings like data truncations while inserting, etc.""" + __module__ = "MySQLdb" + class Error(MySQLError): """Exception that is the base class of all other error exceptions (not Warning).""" + __module__ = "MySQLdb" + class InterfaceError(Error): """Exception raised for errors that are related to the database interface rather than the database itself.""" + __module__ = "MySQLdb" + class DatabaseError(Error): """Exception raised for errors that are related to the database.""" + __module__ = "MySQLdb" + class DataError(DatabaseError): """Exception raised for errors that are due to problems with the processed data like division by zero, numeric value out of range, etc.""" + __module__ = "MySQLdb" + class OperationalError(DatabaseError): """Exception raised for errors that are related to the database's @@ -43,27 +55,37 @@ class OperationalError(DatabaseError): found, a transaction could not be processed, a memory allocation error occurred during processing, etc.""" + __module__ = "MySQLdb" + class IntegrityError(DatabaseError): """Exception raised when the relational integrity of the database is affected, e.g. a foreign key check fails, duplicate key, etc.""" + __module__ = "MySQLdb" + class InternalError(DatabaseError): """Exception raised when the database encounters an internal error, e.g. the cursor is not valid anymore, the transaction is out of sync, etc.""" + __module__ = "MySQLdb" + class ProgrammingError(DatabaseError): """Exception raised for programming errors, e.g. table not found or already exists, syntax error in the SQL statement, wrong number of parameters specified, etc.""" + __module__ = "MySQLdb" + class NotSupportedError(DatabaseError): """Exception raised in case a method or database API was used which is not supported by the database, e.g. requesting a .rollback() on a connection that does not support transaction or has transactions turned off.""" + + __module__ = "MySQLdb" diff --git a/MySQLdb/_mysql.c b/src/MySQLdb/_mysql.c similarity index 92% rename from MySQLdb/_mysql.c rename to src/MySQLdb/_mysql.c index 0f6e9f5c..b9ec1c12 100644 --- a/MySQLdb/_mysql.c +++ b/src/MySQLdb/_mysql.c @@ -25,13 +25,14 @@ USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ - +#include #include "mysql.h" #include "mysqld_error.h" #if MYSQL_VERSION_ID >= 80000 // https://github.com/mysql/mysql-server/commit/eb821c023cedc029ca0b06456dfae365106bee84 -#define my_bool _Bool +// my_bool was typedef of char before MySQL 8.0.0. +#define my_bool bool #endif #if ((MYSQL_VERSION_ID >= 50555 && MYSQL_VERSION_ID <= 50599) || \ @@ -71,7 +72,8 @@ static PyObject *_mysql_NotSupportedError; typedef struct { PyObject_HEAD MYSQL connection; - int open; + bool open; + bool reconnect; PyObject *converter; } _mysql_ConnectionObject; @@ -180,6 +182,7 @@ _mysql_Exception(_mysql_ConnectionObject *c) #ifdef ER_NO_DEFAULT_FOR_FIELD case ER_NO_DEFAULT_FOR_FIELD: #endif + case ER_BAD_NULL_ERROR: e = _mysql_IntegrityError; break; #ifdef ER_WARNING_NOT_COMPLETE_ROLLBACK @@ -310,7 +313,7 @@ _mysql_ResultObject_Initialize( PyObject *fun2=NULL; int j, n2=PySequence_Size(fun); // BINARY_FLAG means ***_bin collation is used. - // To distinguish text and binary, we shoud use charsetnr==63 (binary). + // To distinguish text and binary, we should use charsetnr==63 (binary). // But we abuse BINARY_FLAG for historical reason. if (fields[i].charsetnr == 63) { flags |= BINARY_FLAG; @@ -379,12 +382,19 @@ static int _mysql_ResultObject_clear(_mysql_ResultObject *self) return 0; } -#ifdef HAVE_ENUM_MYSQL_OPT_SSL_MODE +enum { + SSLMODE_DISABLED = 1, + SSLMODE_PREFERRED = 2, + SSLMODE_REQUIRED = 3, + SSLMODE_VERIFY_CA = 4, + SSLMODE_VERIFY_IDENTITY = 5 +}; + static int -_get_ssl_mode_num(char *ssl_mode) +_get_ssl_mode_num(const char *ssl_mode) { - static char *ssl_mode_list[] = { "DISABLED", "PREFERRED", - "REQUIRED", "VERIFY_CA", "VERIFY_IDENTITY" }; + static const char *ssl_mode_list[] = { + "DISABLED", "PREFERRED", "REQUIRED", "VERIFY_CA", "VERIFY_IDENTITY" }; unsigned int i; for (i=0; i < sizeof(ssl_mode_list)/sizeof(ssl_mode_list[0]); i++) { if (strcmp(ssl_mode, ssl_mode_list[i]) == 0) { @@ -394,7 +404,6 @@ _get_ssl_mode_num(char *ssl_mode) } return -1; } -#endif static int _mysql_ConnectionObject_Initialize( @@ -405,7 +414,7 @@ _mysql_ConnectionObject_Initialize( MYSQL *conn = NULL; PyObject *conv = NULL; PyObject *ssl = NULL; - char *ssl_mode = NULL; + const char *ssl_mode = NULL; const char *key = NULL, *cert = NULL, *ca = NULL, *capath = NULL, *cipher = NULL; PyObject *ssl_keepref[5] = {NULL}; @@ -428,6 +437,7 @@ _mysql_ConnectionObject_Initialize( int read_timeout = 0; int write_timeout = 0; int compress = -1, named_pipe = -1, local_infile = -1; + int ssl_mode_num = SSLMODE_PREFERRED; char *init_command=NULL, *read_default_file=NULL, *read_default_group=NULL, @@ -435,7 +445,8 @@ _mysql_ConnectionObject_Initialize( *auth_plugin=NULL; self->converter = NULL; - self->open = 0; + self->open = false; + self->reconnect = false; if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|ssssisOiiisssiOsiiiss:connect", @@ -459,24 +470,31 @@ _mysql_ConnectionObject_Initialize( if(t){d=PyUnicode_AsUTF8(t);ssl_keepref[n_ssl_keepref++]=t;}\ PyErr_Clear();} + char ssl_mode_set = 0; if (ssl) { - PyObject *value = NULL; - _stringsuck(ca, value, ssl); - _stringsuck(capath, value, ssl); - _stringsuck(cert, value, ssl); - _stringsuck(key, value, ssl); - _stringsuck(cipher, value, ssl); + if (PyMapping_Check(ssl)) { + PyObject *value = NULL; + _stringsuck(ca, value, ssl); + _stringsuck(capath, value, ssl); + _stringsuck(cert, value, ssl); + _stringsuck(key, value, ssl); + _stringsuck(cipher, value, ssl); + } else if (PyObject_IsTrue(ssl)) { + // Support ssl=True from mysqlclient 2.2.4. + // for compatibility with PyMySQL and mysqlclient==2.2.1&libmariadb. + ssl_mode_num = SSLMODE_REQUIRED; + ssl_mode_set = 1; + } else { + ssl_mode_num = SSLMODE_DISABLED; + ssl_mode_set = 1; + } } if (ssl_mode) { -#ifdef HAVE_ENUM_MYSQL_OPT_SSL_MODE - if (_get_ssl_mode_num(ssl_mode) <= 0) { + if ((ssl_mode_num = _get_ssl_mode_num(ssl_mode)) <= 0) { PyErr_SetString(_mysql_NotSupportedError, "Unknown ssl_mode specification"); return -1; } -#else - PyErr_SetString(_mysql_NotSupportedError, "MySQL client library does not support ssl_mode specification"); - return -1; -#endif + ssl_mode_set = 1; } conn = mysql_init(&(self->connection)); @@ -484,8 +502,8 @@ _mysql_ConnectionObject_Initialize( PyErr_SetNone(PyExc_MemoryError); return -1; } - Py_BEGIN_ALLOW_THREADS ; - self->open = 1; + self->open = true; + if (connect_timeout) { unsigned int timeout = connect_timeout; mysql_options(&(self->connection), MYSQL_OPT_CONNECT_TIMEOUT, @@ -518,14 +536,31 @@ _mysql_ConnectionObject_Initialize( mysql_options(&(self->connection), MYSQL_OPT_LOCAL_INFILE, (char *) &local_infile); if (ssl) { - mysql_ssl_set(&(self->connection), key, cert, ca, capath, cipher); + mysql_options(&(self->connection), MYSQL_OPT_SSL_KEY, key); + mysql_options(&(self->connection), MYSQL_OPT_SSL_CERT, cert); + mysql_options(&(self->connection), MYSQL_OPT_SSL_CA, ca); + mysql_options(&(self->connection), MYSQL_OPT_SSL_CAPATH, capath); + mysql_options(&(self->connection), MYSQL_OPT_SSL_CIPHER, cipher); } + + if (ssl_mode_set) { #ifdef HAVE_ENUM_MYSQL_OPT_SSL_MODE - if (ssl_mode) { - int ssl_mode_num = _get_ssl_mode_num(ssl_mode); mysql_options(&(self->connection), MYSQL_OPT_SSL_MODE, &ssl_mode_num); - } +#else + // MariaDB doesn't support MYSQL_OPT_SSL_MODE. + // See https://github.com/PyMySQL/mysqlclient/issues/474 + // TODO: Does MariaDB supports PREFERRED and VERIFY_CA? + // We support only two levels for now. + my_bool enforce_tls = 1; + if (ssl_mode_num >= SSLMODE_REQUIRED) { + mysql_optionsv(&(self->connection), MYSQL_OPT_SSL_ENFORCE, (void *)&enforce_tls); + } + if (ssl_mode_num >= SSLMODE_VERIFY_CA) { + mysql_optionsv(&(self->connection), MYSQL_OPT_SSL_VERIFY_SERVER_CERT, (void *)&enforce_tls); + } #endif + } + if (charset) { mysql_options(&(self->connection), MYSQL_SET_CHARSET_NAME, charset); } @@ -533,10 +568,10 @@ _mysql_ConnectionObject_Initialize( mysql_options(&(self->connection), MYSQL_DEFAULT_AUTH, auth_plugin); } + Py_BEGIN_ALLOW_THREADS conn = mysql_real_connect(&(self->connection), host, user, passwd, db, port, unix_socket, client_flag); - - Py_END_ALLOW_THREADS ; + Py_END_ALLOW_THREADS if (ssl) { int i; @@ -581,10 +616,10 @@ host\n\ user\n\ string, user to connect as\n\ \n\ -passwd\n\ +password\n\ string, password to use\n\ \n\ -db\n\ +database\n\ string, database to use\n\ \n\ port\n\ @@ -671,7 +706,7 @@ _mysql_ConnectionObject_close( Py_BEGIN_ALLOW_THREADS mysql_close(&(self->connection)); Py_END_ALLOW_THREADS - self->open = 0; + self->open = false; _mysql_ConnectionObject_clear(self); Py_RETURN_NONE; } @@ -929,7 +964,7 @@ _mysql_escape_string( { PyObject *str; char *in, *out; - int len; + unsigned long len; Py_ssize_t size; if (!PyArg_ParseTuple(args, "s#:escape_string", &in, &size)) return NULL; str = PyBytes_FromStringAndSize((char *) NULL, size*2+1); @@ -966,10 +1001,7 @@ _mysql_string_literal( _mysql_ConnectionObject *self, PyObject *o) { - PyObject *str, *s; - char *in, *out; - unsigned long len; - Py_ssize_t size; + PyObject *s; // input string or bytes. need to decref. if (self && PyModule_Check((PyObject*)self)) self = NULL; @@ -977,24 +1009,44 @@ _mysql_string_literal( if (PyBytes_Check(o)) { s = o; Py_INCREF(s); - } else { - s = PyObject_Str(o); - if (!s) return NULL; - { - PyObject *t = PyUnicode_AsASCIIString(s); - Py_DECREF(s); - if (!t) return NULL; + } + else { + PyObject *t = PyObject_Str(o); + if (!t) return NULL; + + const char *encoding = (self && self->open) ? + _get_encoding(&self->connection) : utf8; + if (encoding == utf8) { s = t; } + else { + s = PyUnicode_AsEncodedString(t, encoding, "strict"); + Py_DECREF(t); + if (!s) return NULL; + } } - in = PyBytes_AsString(s); - size = PyBytes_GET_SIZE(s); - str = PyBytes_FromStringAndSize((char *) NULL, size*2+3); + + // Prepare input string (in, size) + const char *in; + Py_ssize_t size; + if (PyUnicode_Check(s)) { + in = PyUnicode_AsUTF8AndSize(s, &size); + } else { + assert(PyBytes_Check(s)); + in = PyBytes_AsString(s); + size = PyBytes_GET_SIZE(s); + } + + // Prepare output buffer (str, out) + PyObject *str = PyBytes_FromStringAndSize((char *) NULL, size*2+3); if (!str) { Py_DECREF(s); return PyErr_NoMemory(); } - out = PyBytes_AS_STRING(str); + char *out = PyBytes_AS_STRING(str); + + // escape + unsigned long len; if (self && self->open) { #if MYSQL_VERSION_ID >= 50707 && !defined(MARIADB_BASE_VERSION) && !defined(MARIADB_VERSION_ID) len = mysql_real_escape_string_quote(&(self->connection), out+1, in, size, '\''); @@ -1004,10 +1056,14 @@ _mysql_string_literal( } else { len = mysql_escape_string(out+1, in, size); } - *out = *(out+len+1) = '\''; - if (_PyBytes_Resize(&str, len+2) < 0) return NULL; + Py_DECREF(s); - return (str); + *out = *(out+len+1) = '\''; + if (_PyBytes_Resize(&str, len+2) < 0) { + Py_DECREF(str); + return NULL; + } + return str; } static PyObject * @@ -1388,9 +1444,9 @@ _mysql__fetch_row( if (!self->use) row = mysql_fetch_row(self->result); else { - Py_BEGIN_ALLOW_THREADS; + Py_BEGIN_ALLOW_THREADS row = mysql_fetch_row(self->result); - Py_END_ALLOW_THREADS; + Py_END_ALLOW_THREADS } if (!row && mysql_errno(&(((_mysql_ConnectionObject *)(self->conn))->connection))) { _mysql_Exception((_mysql_ConnectionObject *)self->conn); @@ -1442,7 +1498,7 @@ _mysql_ResultObject_fetch_row( &maxrows, &how)) return NULL; check_result_connection(self); - if (how >= (int)sizeof(row_converters)) { + if (how >= (int)(sizeof(row_converters) / sizeof(row_converters[0]))) { PyErr_SetString(PyExc_ValueError, "how out of range"); return NULL; } @@ -1469,6 +1525,29 @@ _mysql_ResultObject_fetch_row( return NULL; } +static const char _mysql_ResultObject_discard__doc__[] = +"discard() -- Discard remaining rows in the resultset."; + +static PyObject * +_mysql_ResultObject_discard( + _mysql_ResultObject *self, + PyObject *noargs) +{ + check_result_connection(self); + + MYSQL_ROW row; + Py_BEGIN_ALLOW_THREADS + while (NULL != (row = mysql_fetch_row(self->result))) { + // do nothing + } + Py_END_ALLOW_THREADS + _mysql_ConnectionObject *conn = (_mysql_ConnectionObject *)self->conn; + if (mysql_errno(&conn->connection)) { + return _mysql_Exception(conn); + } + Py_RETURN_NONE; +} + static char _mysql_ConnectionObject_change_user__doc__[] = "Changes the user and causes the database specified by db to\n\ become the default (current) database on the connection\n\ @@ -1712,15 +1791,13 @@ _mysql_ConnectionObject_insert_id( { my_ulonglong r; check_connection(self); - Py_BEGIN_ALLOW_THREADS r = mysql_insert_id(&(self->connection)); - Py_END_ALLOW_THREADS return PyLong_FromUnsignedLongLong(r); } static char _mysql_ConnectionObject_kill__doc__[] = "Asks the server to kill the thread specified by pid.\n\ -Non-standard."; +Non-standard. Deprecated."; static PyObject * _mysql_ConnectionObject_kill( @@ -1729,10 +1806,12 @@ _mysql_ConnectionObject_kill( { unsigned long pid; int r; + char query[50]; if (!PyArg_ParseTuple(args, "k:kill", &pid)) return NULL; check_connection(self); + snprintf(query, 50, "KILL %lu", pid); Py_BEGIN_ALLOW_THREADS - r = mysql_kill(&(self->connection), pid); + r = mysql_query(&(self->connection), query); Py_END_ALLOW_THREADS if (r) return _mysql_Exception(self); Py_RETURN_NONE; @@ -1795,18 +1874,18 @@ _mysql_ResultObject_num_rows( } static char _mysql_ConnectionObject_ping__doc__[] = -"Checks whether or not the connection to the server is\n\ -working. If it has gone down, an automatic reconnection is\n\ -attempted.\n\ +"Checks whether or not the connection to the server is working.\n\ \n\ This function can be used by clients that remain idle for a\n\ long while, to check whether or not the server has closed the\n\ -connection and reconnect if necessary.\n\ +connection.\n\ \n\ New in 1.2.2: Accepts an optional reconnect parameter. If True,\n\ then the client will attempt reconnection. Note that this setting\n\ is persistent. By default, this is on in MySQL<5.0.3, and off\n\ thereafter.\n\ +MySQL 8.0.33 deprecated the MYSQL_OPT_RECONNECT option so reconnect\n\ +parameter is also deprecated in mysqlclient 2.2.1.\n\ \n\ Non-standard. You should assume that ping() performs an\n\ implicit rollback; use only when starting a new transaction.\n\ @@ -1818,17 +1897,24 @@ _mysql_ConnectionObject_ping( _mysql_ConnectionObject *self, PyObject *args) { - int r, reconnect = -1; - if (!PyArg_ParseTuple(args, "|I", &reconnect)) return NULL; + int reconnect = 0; + if (!PyArg_ParseTuple(args, "|p", &reconnect)) return NULL; check_connection(self); - if (reconnect != -1) { + if (reconnect != (self->reconnect == true)) { + // libmysqlclient show warning to stderr when MYSQL_OPT_RECONNECT is used. + // so we avoid using it as possible for now. + // TODO: Warn when reconnect is true. + // MySQL 8.0.33 show warning to stderr already. + // We will emit Pytohn warning in future. my_bool recon = (my_bool)reconnect; mysql_options(&self->connection, MYSQL_OPT_RECONNECT, &recon); + self->reconnect = (bool)reconnect; } + int r; Py_BEGIN_ALLOW_THREADS r = mysql_ping(&(self->connection)); Py_END_ALLOW_THREADS - if (r) return _mysql_Exception(self); + if (r) return _mysql_Exception(self); Py_RETURN_NONE; } @@ -1930,7 +2016,7 @@ _mysql_ConnectionObject_select_db( static char _mysql_ConnectionObject_shutdown__doc__[] = "Asks the database server to shut down. The connected user must\n\ -have shutdown privileges. Non-standard.\n\ +have shutdown privileges. Non-standard. Deprecated.\n\ "; static PyObject * @@ -1941,7 +2027,7 @@ _mysql_ConnectionObject_shutdown( int r; check_connection(self); Py_BEGIN_ALLOW_THREADS - r = mysql_shutdown(&(self->connection), SHUTDOWN_DEFAULT); + r = mysql_query(&(self->connection), "SHUTDOWN"); Py_END_ALLOW_THREADS if (r) return _mysql_Exception(self); Py_RETURN_NONE; @@ -2023,9 +2109,7 @@ _mysql_ConnectionObject_thread_id( { unsigned long pid; check_connection(self); - Py_BEGIN_ALLOW_THREADS pid = mysql_thread_id(&(self->connection)); - Py_END_ALLOW_THREADS return PyLong_FromLong((long)pid); } @@ -2066,6 +2150,43 @@ _mysql_ConnectionObject_use_result( return result; } +static const char _mysql_ConnectionObject_discard_result__doc__[] = +"Discard current result set.\n\n" +"This function can be called instead of use_result() or store_result(). Non-standard."; + +static PyObject * +_mysql_ConnectionObject_discard_result( + _mysql_ConnectionObject *self, + PyObject *noargs) +{ + check_connection(self); + MYSQL *conn = &(self->connection); + + Py_BEGIN_ALLOW_THREADS; + + MYSQL_RES *res = mysql_use_result(conn); + if (res == NULL) { + Py_BLOCK_THREADS; + if (mysql_errno(conn) != 0) { + // fprintf(stderr, "mysql_use_result failed: %s\n", mysql_error(conn)); + return _mysql_Exception(self); + } + Py_RETURN_NONE; + } + + MYSQL_ROW row; + while (NULL != (row = mysql_fetch_row(res))) { + // do nothing. + } + mysql_free_result(res); + Py_END_ALLOW_THREADS; + if (mysql_errno(conn)) { + // fprintf(stderr, "mysql_free_result failed: %s\n", mysql_error(conn)); + return _mysql_Exception(self); + } + Py_RETURN_NONE; +} + static void _mysql_ConnectionObject_dealloc( _mysql_ConnectionObject *self) @@ -2073,7 +2194,7 @@ _mysql_ConnectionObject_dealloc( PyObject_GC_UnTrack(self); if (self->open) { mysql_close(&(self->connection)); - self->open = 0; + self->open = false; } Py_CLEAR(self->converter); MyFree(self); @@ -2361,6 +2482,12 @@ static PyMethodDef _mysql_ConnectionObject_methods[] = { METH_NOARGS, _mysql_ConnectionObject_use_result__doc__ }, + { + "discard_result", + (PyCFunction)_mysql_ConnectionObject_discard_result, + METH_NOARGS, + _mysql_ConnectionObject_discard_result__doc__ + }, {NULL, NULL} /* sentinel */ }; @@ -2422,6 +2549,12 @@ static PyMethodDef _mysql_ResultObject_methods[] = { METH_VARARGS | METH_KEYWORDS, _mysql_ResultObject_fetch_row__doc__ }, + { + "discard", + (PyCFunction)_mysql_ResultObject_discard, + METH_NOARGS, + _mysql_ResultObject_discard__doc__ + }, { "field_flags", (PyCFunction)_mysql_ResultObject_field_flags, diff --git a/MySQLdb/connections.py b/src/MySQLdb/connections.py similarity index 91% rename from MySQLdb/connections.py rename to src/MySQLdb/connections.py index 38324665..0d2627ca 100644 --- a/MySQLdb/connections.py +++ b/src/MySQLdb/connections.py @@ -97,6 +97,14 @@ class object, used to create cursors (keyword only) If supplied, the connection character set will be changed to this character set. + :param str collation: + If ``charset`` and ``collation`` are both supplied, the + character set and collation for the current connection + will be set. + + If omitted, empty string, or None, the default collation + for the ``charset`` is implied. + :param str auth_plugin: If supplied, the connection default authentication plugin will be changed to this value. Example values: @@ -126,6 +134,8 @@ class object, used to create cursors (keyword only) see the MySQL documentation for more details (mysql_ssl_set()). If this is set, and the client does not support SSL, NotSupportedError will be raised. + Since mysqlclient 2.2.4, ssl=True is alias of ssl_mode=REQUIRED + for better compatibility with PyMySQL and MariaDB. :param bool local_infile: enables LOAD LOCAL INFILE; zero disables @@ -144,7 +154,6 @@ class object, used to create cursors (keyword only) """ from MySQLdb.constants import CLIENT, FIELD_TYPE from MySQLdb.converters import conversions, _bytes_or_str - from weakref import proxy kwargs2 = kwargs.copy() @@ -168,6 +177,7 @@ class object, used to create cursors (keyword only) cursorclass = kwargs2.pop("cursorclass", self.default_cursor) charset = kwargs2.get("charset", "") + collation = kwargs2.pop("collation", "") use_unicode = kwargs2.pop("use_unicode", True) sql_mode = kwargs2.pop("sql_mode", "") self._binary_prefix = kwargs2.pop("binary_prefix", False) @@ -184,7 +194,11 @@ class object, used to create cursors (keyword only) super().__init__(*args, **kwargs2) self.cursorclass = cursorclass - self.encoders = {k: v for k, v in conv.items() if type(k) is not int} + self.encoders = { + k: v + for k, v in conv.items() + if type(k) is not int # noqa: E721 + } self._server_version = tuple( [numeric_part(n) for n in self.get_server_info().split(".")[:2]] @@ -194,7 +208,7 @@ class object, used to create cursors (keyword only) if not charset: charset = self.character_set_name() - self.set_character_set(charset) + self.set_character_set(charset, collation) if sql_mode: self.set_sql_mode(sql_mode) @@ -208,19 +222,16 @@ class object, used to create cursors (keyword only) FIELD_TYPE.MEDIUM_BLOB, FIELD_TYPE.LONG_BLOB, FIELD_TYPE.BLOB, + FIELD_TYPE.SET, + FIELD_TYPE.ENUMS, + FIELD_TYPE.BINARY, + FIELD_TYPE.CHAR ): self.converter[t] = _bytes_or_str # Unlike other string/blob types, JSON is always text. # MySQL may return JSON with charset==binary. self.converter[FIELD_TYPE.JSON] = str - db = proxy(self) - - def unicode_literal(u, dummy=None): - return db.string_literal(u.encode(db.encoding)) - - self.encoders[str] = unicode_literal - self._transactional = self.server_capabilities & CLIENT.TRANSACTIONS if self._transactional: if autocommit is not None: @@ -293,10 +304,13 @@ def begin(self): """ self.query(b"BEGIN") - def set_character_set(self, charset): + def set_character_set(self, charset, collation=None): """Set the connection character set to charset.""" super().set_character_set(charset) self.encoding = _charset_to_encoding.get(charset, charset) + if collation: + self.query(f"SET NAMES {charset} COLLATE {collation}") + self.store_result() def set_sql_mode(self, sql_mode): """Set the connection sql_mode. See MySQL documentation for diff --git a/MySQLdb/constants/CLIENT.py b/src/MySQLdb/constants/CLIENT.py similarity index 100% rename from MySQLdb/constants/CLIENT.py rename to src/MySQLdb/constants/CLIENT.py diff --git a/MySQLdb/constants/CR.py b/src/MySQLdb/constants/CR.py similarity index 98% rename from MySQLdb/constants/CR.py rename to src/MySQLdb/constants/CR.py index 9d33cf65..9467ae11 100644 --- a/MySQLdb/constants/CR.py +++ b/src/MySQLdb/constants/CR.py @@ -29,7 +29,7 @@ data[value].add(name) for value, names in sorted(data.items()): for name in sorted(names): - print("{} = {}".format(name, value)) + print(f"{name} = {value}") if error_last is not None: print("ERROR_LAST = %s" % error_last) diff --git a/MySQLdb/constants/ER.py b/src/MySQLdb/constants/ER.py similarity index 99% rename from MySQLdb/constants/ER.py rename to src/MySQLdb/constants/ER.py index fcd5bf2e..8c5ece24 100644 --- a/MySQLdb/constants/ER.py +++ b/src/MySQLdb/constants/ER.py @@ -30,7 +30,7 @@ data[value].add(name) for value, names in sorted(data.items()): for name in sorted(names): - print("{} = {}".format(name, value)) + print(f"{name} = {value}") if error_last is not None: print("ERROR_LAST = %s" % error_last) diff --git a/MySQLdb/constants/FIELD_TYPE.py b/src/MySQLdb/constants/FIELD_TYPE.py similarity index 100% rename from MySQLdb/constants/FIELD_TYPE.py rename to src/MySQLdb/constants/FIELD_TYPE.py diff --git a/MySQLdb/constants/FLAG.py b/src/MySQLdb/constants/FLAG.py similarity index 100% rename from MySQLdb/constants/FLAG.py rename to src/MySQLdb/constants/FLAG.py diff --git a/MySQLdb/constants/__init__.py b/src/MySQLdb/constants/__init__.py similarity index 100% rename from MySQLdb/constants/__init__.py rename to src/MySQLdb/constants/__init__.py diff --git a/MySQLdb/converters.py b/src/MySQLdb/converters.py similarity index 98% rename from MySQLdb/converters.py rename to src/MySQLdb/converters.py index 33f22f74..d6fdc01c 100644 --- a/MySQLdb/converters.py +++ b/src/MySQLdb/converters.py @@ -72,7 +72,7 @@ def Thing2Str(s, d): def Float2Str(o, d): s = repr(o) - if s in ("inf", "nan"): + if s in ("inf", "-inf", "nan"): raise ProgrammingError("%s can not be used with MySQL" % s) if "e" not in s: s += "e0" diff --git a/MySQLdb/cursors.py b/src/MySQLdb/cursors.py similarity index 88% rename from MySQLdb/cursors.py rename to src/MySQLdb/cursors.py index 451dab5f..70fbeea4 100644 --- a/MySQLdb/cursors.py +++ b/src/MySQLdb/cursors.py @@ -8,7 +8,7 @@ from ._exceptions import ProgrammingError -#: Regular expression for :meth:`Cursor.executemany`. +#: Regular expression for ``Cursor.executemany```. #: executemany only supports simple bulk insert. #: You can use it to load large dataset. RE_INSERT_VALUES = re.compile( @@ -66,7 +66,7 @@ def __init__(self, connection): self.connection = connection self.description = None self.description_flags = None - self.rowcount = -1 + self.rowcount = 0 self.arraysize = 1 self._executed = None @@ -75,13 +75,32 @@ def __init__(self, connection): self.rownumber = None self._rows = None + def _discard(self): + self.description = None + self.description_flags = None + # Django uses some member after __exit__. + # So we keep rowcount and lastrowid here. They are cleared in Cursor._query(). + # self.rowcount = 0 + # self.lastrowid = None + self._rows = None + self.rownumber = None + + if self._result: + self._result.discard() + self._result = None + + con = self.connection + if con is None: + return + while con.next_result() == 0: # -1 means no more data. + con.discard_result() + def close(self): """Close the cursor. No further queries will be possible.""" try: if self.connection is None: return - while self.nextset(): - pass + self._discard() finally: self.connection = None self._result = None @@ -93,34 +112,6 @@ def __exit__(self, *exc_info): del exc_info self.close() - def _escape_args(self, args, conn): - encoding = conn.encoding - literal = conn.literal - - def ensure_bytes(x): - if isinstance(x, str): - return x.encode(encoding) - elif isinstance(x, tuple): - return tuple(map(ensure_bytes, x)) - elif isinstance(x, list): - return list(map(ensure_bytes, x)) - return x - - if isinstance(args, (tuple, list)): - ret = tuple(literal(ensure_bytes(arg)) for arg in args) - elif isinstance(args, dict): - ret = { - ensure_bytes(key): literal(ensure_bytes(val)) - for (key, val) in args.items() - } - else: - # If it's not a dictionary let's try escaping it anyways. - # Worst case it will throw a Value error - ret = literal(ensure_bytes(args)) - - ensure_bytes = None # break circular reference - return ret - def _check_executed(self): if not self._executed: raise ProgrammingError("execute() first") @@ -180,8 +171,16 @@ def execute(self, query, args=None): Returns integer represents rows affected, if any """ - while self.nextset(): - pass + self._discard() + + mogrified_query = self._mogrify(query, args) + + assert isinstance(mogrified_query, (bytes, bytearray)) + res = self._query(mogrified_query) + return res + + def _mogrify(self, query, args=None): + """Return query after binding args.""" db = self._get_db() if isinstance(query, str): @@ -202,9 +201,21 @@ def execute(self, query, args=None): except TypeError as m: raise ProgrammingError(str(m)) - assert isinstance(query, (bytes, bytearray)) - res = self._query(query) - return res + return query + + def mogrify(self, query, args=None): + """Return query after binding args. + + query -- string, query to mogrify + args -- optional sequence or mapping, parameters to use with query. + + Note: If args is a sequence, then %s must be used as the + parameter placeholder in the query. If a mapping is used, + %(key)s must be used as the placeholder. + + Returns string representing query that would be executed by the server + """ + return self._mogrify(query, args).decode(self._get_db().encoding) def executemany(self, query, args): # type: (str, list) -> int @@ -242,8 +253,6 @@ def executemany(self, query, args): def _do_execute_many( self, prefix, values, postfix, args, max_stmt_length, encoding ): - conn = self._get_db() - escape = self._escape_args if isinstance(prefix, str): prefix = prefix.encode(encoding) if isinstance(values, str): @@ -252,11 +261,11 @@ def _do_execute_many( postfix = postfix.encode(encoding) sql = bytearray(prefix) args = iter(args) - v = values % escape(next(args), conn) + v = self._mogrify(values, next(args)) sql += v rows = 0 for arg in args: - v = values % escape(arg, conn) + v = self._mogrify(values, arg) if len(sql) + len(v) + len(postfix) + 1 > max_stmt_length: rows += self.execute(sql + postfix) sql = bytearray(prefix) @@ -316,6 +325,8 @@ def callproc(self, procname, args=()): def _query(self, q): db = self._get_db() self._result = None + self.rowcount = None + self.lastrowid = None db.query(q) self._do_get_result(db) self._post_get_result() @@ -375,7 +386,7 @@ def fetchmany(self, size=None): return result def fetchall(self): - """Fetchs all available rows from the cursor.""" + """Fetches all available rows from the cursor.""" self._check_executed() if self.rownumber: result = self._rows[self.rownumber :] @@ -437,7 +448,7 @@ def fetchmany(self, size=None): return r def fetchall(self): - """Fetchs all available rows from the cursor.""" + """Fetches all available rows from the cursor.""" self._check_executed() r = self._fetch_row(0) self.rownumber = self.rownumber + len(r) diff --git a/src/MySQLdb/release.py b/src/MySQLdb/release.py new file mode 100644 index 00000000..35d53e20 --- /dev/null +++ b/src/MySQLdb/release.py @@ -0,0 +1,3 @@ +__author__ = "Inada Naoki " +__version__ = "2.2.4" +version_info = (2, 2, 4, "final", 0) diff --git a/MySQLdb/times.py b/src/MySQLdb/times.py similarity index 100% rename from MySQLdb/times.py rename to src/MySQLdb/times.py diff --git a/tests/capabilities.py b/tests/capabilities.py index da753d15..1e695e9e 100644 --- a/tests/capabilities.py +++ b/tests/capabilities.py @@ -11,7 +11,6 @@ class DatabaseTest(unittest.TestCase): - db_module = None connect_args = () connect_kwargs = dict() @@ -20,7 +19,6 @@ class DatabaseTest(unittest.TestCase): debug = False def setUp(self): - db = connection_factory(**self.connect_kwargs) self.connection = db self.cursor = db.cursor() @@ -37,13 +35,13 @@ def tearDown(self): del self.cursor orphans = gc.collect() - self.failIf( + self.assertFalse( orphans, "%d orphaned objects found after deleting cursor" % orphans ) del self.connection orphans = gc.collect() - self.failIf( + self.assertFalse( orphans, "%d orphaned objects found after deleting connection" % orphans ) @@ -67,7 +65,6 @@ def new_table_name(self): i = i + 1 def create_table(self, columndefs): - """Create a table using a list of column definitions given in columndefs. @@ -85,7 +82,7 @@ def create_table(self, columndefs): def check_data_integrity(self, columndefs, generator): # insert self.create_table(columndefs) - insert_statement = "INSERT INTO %s VALUES (%s)" % ( + insert_statement = "INSERT INTO {} VALUES ({})".format( self.table, ",".join(["%s"] * len(columndefs)), ) @@ -116,7 +113,7 @@ def generator(row, col): return ("%i" % (row % 10)) * 255 self.create_table(columndefs) - insert_statement = "INSERT INTO %s VALUES (%s)" % ( + insert_statement = "INSERT INTO {} VALUES ({})".format( self.table, ",".join(["%s"] * len(columndefs)), ) @@ -134,11 +131,11 @@ def generator(row, col): self.assertEqual(res[i][j], generator(i, j)) delete_statement = "delete from %s where col1=%%s" % self.table self.cursor.execute(delete_statement, (0,)) - self.cursor.execute("select col1 from %s where col1=%s" % (self.table, 0)) + self.cursor.execute(f"select col1 from {self.table} where col1=%s", (0,)) res = self.cursor.fetchall() self.assertFalse(res, "DELETE didn't work") self.connection.rollback() - self.cursor.execute("select col1 from %s where col1=%s" % (self.table, 0)) + self.cursor.execute(f"select col1 from {self.table} where col1=%s", (0,)) res = self.cursor.fetchall() self.assertTrue(len(res) == 1, "ROLLBACK didn't work") self.cursor.execute("drop table %s" % (self.table)) @@ -153,7 +150,7 @@ def generator(row, col): return ("%i" % (row % 10)) * ((255 - self.rows // 2) + row) self.create_table(columndefs) - insert_statement = "INSERT INTO %s VALUES (%s)" % ( + insert_statement = "INSERT INTO {} VALUES ({})".format( self.table, ",".join(["%s"] * len(columndefs)), ) diff --git a/tests/dbapi20.py b/tests/dbapi20.py index 4824d9cc..be0f6292 100644 --- a/tests/dbapi20.py +++ b/tests/dbapi20.py @@ -56,7 +56,7 @@ # - self.populate is now self._populate(), so if a driver stub # 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) +# DDL even more portable (this will be reversed if it causes more problems) # - cursor.rowcount being checked after various execute and fetchXXX methods # - Check for fetchall and fetchmany returning empty lists after results # are exhausted (already checking for empty lists if select retrieved @@ -525,8 +525,7 @@ def _populate(self): tests. """ populate = [ - "insert into {}booze values ('{}')".format(self.table_prefix, s) - for s in self.samples + f"insert into {self.table_prefix}booze values ('{s}')" for s in self.samples ] return populate @@ -793,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): diff --git a/tests/default.cnf b/tests/default.cnf index 2aeda7cf..1d6c9421 100644 --- a/tests/default.cnf +++ b/tests/default.cnf @@ -2,9 +2,10 @@ # http://dev.mysql.com/doc/refman/5.1/en/option-files.html # and set TESTDB in your environment to the name of the file +# $ docker run -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -p 3306:3306 --rm --name mysqld mysql:latest [MySQLdb-tests] host = 127.0.0.1 -user = test +user = root database = test #password = -default-character-set = utf8 +default-character-set = utf8mb4 diff --git a/tests/test_MySQLdb_capabilities.py b/tests/test_MySQLdb_capabilities.py index fc213b84..dbff27c2 100644 --- a/tests/test_MySQLdb_capabilities.py +++ b/tests/test_MySQLdb_capabilities.py @@ -12,7 +12,6 @@ class test_MySQLdb(capabilities.DatabaseTest): - db_module = MySQLdb connect_args = () connect_kwargs = dict( diff --git a/tests/test_MySQLdb_nonstandard.py b/tests/test_MySQLdb_nonstandard.py index c517dad3..5e841791 100644 --- a/tests/test_MySQLdb_nonstandard.py +++ b/tests/test_MySQLdb_nonstandard.py @@ -114,3 +114,33 @@ def test_context_manager(self): with connection_factory() as conn: self.assertFalse(conn.closed) self.assertTrue(conn.closed) + + +class TestCollation(unittest.TestCase): + """Test charset and collation connection options.""" + + def setUp(self): + # Initialize a connection with a non-default character set and + # collation. + self.conn = connection_factory( + charset="utf8mb4", + collation="utf8mb4_esperanto_ci", + ) + + def tearDown(self): + self.conn.close() + + def test_charset_collation(self): + c = self.conn.cursor() + c.execute( + """ + SHOW VARIABLES WHERE + Variable_Name="character_set_connection" OR + Variable_Name="collation_connection"; + """ + ) + row = c.fetchall() + charset = row[0][1] + collation = row[1][1] + self.assertEqual(charset, "utf8mb4") + self.assertEqual(collation, "utf8mb4_esperanto_ci") diff --git a/tests/test_cursor.py b/tests/test_cursor.py index 91f0323e..1d2c3655 100644 --- a/tests/test_cursor.py +++ b/tests/test_cursor.py @@ -1,4 +1,4 @@ -# import pytest +import pytest import MySQLdb.cursors from configdb import connection_factory @@ -18,7 +18,7 @@ def teardown_function(function): c = _conns[0] cur = c.cursor() for t in _tables: - cur.execute("DROP TABLE {}".format(t)) + cur.execute(f"DROP TABLE {t}") cur.close() del _tables[:] @@ -72,7 +72,7 @@ def test_executemany(): # values (0),(1),(2),(3),(4),(5),(6),(7),(8),(9) # """ # list args - data = range(10) + data = [(i,) for i in range(10)] cursor.executemany("insert into test (data) values (%s)", data) assert cursor._executed.endswith( b",(7),(8),(9)" @@ -150,3 +150,96 @@ def test_dictcursor(): names2 = sorted(rows[1]) for a, b in zip(names1, names2): assert a is b + + +def test_mogrify_without_args(): + conn = connect() + cursor = conn.cursor() + + query = "SELECT VERSION()" + mogrified_query = cursor.mogrify(query) + cursor.execute(query) + + assert mogrified_query == query + assert mogrified_query == cursor._executed.decode() + + +def test_mogrify_with_tuple_args(): + conn = connect() + cursor = conn.cursor() + + query_with_args = "SELECT %s, %s", (1, 2) + mogrified_query = cursor.mogrify(*query_with_args) + cursor.execute(*query_with_args) + + assert mogrified_query == "SELECT 1, 2" + assert mogrified_query == cursor._executed.decode() + + +def test_mogrify_with_dict_args(): + conn = connect() + cursor = conn.cursor() + + query_with_args = "SELECT %(a)s, %(b)s", {"a": 1, "b": 2} + mogrified_query = cursor.mogrify(*query_with_args) + cursor.execute(*query_with_args) + + assert mogrified_query == "SELECT 1, 2" + assert mogrified_query == cursor._executed.decode() + + +# Test that cursor can be used without reading whole resultset. +@pytest.mark.parametrize("Cursor", [MySQLdb.cursors.Cursor, MySQLdb.cursors.SSCursor]) +def test_cursor_discard_result(Cursor): + conn = connect() + cursor = conn.cursor(Cursor) + + cursor.execute( + """\ +CREATE TABLE test_cursor_discard_result ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + data VARCHAR(100) +)""" + ) + _tables.append("test_cursor_discard_result") + + cursor.executemany( + "INSERT INTO test_cursor_discard_result (id, data) VALUES (%s, %s)", + [(i, f"row {i}") for i in range(1, 101)], + ) + + cursor.execute( + """\ +SELECT * FROM test_cursor_discard_result WHERE id <= 10; +SELECT * FROM test_cursor_discard_result WHERE id BETWEEN 11 AND 20; +SELECT * FROM test_cursor_discard_result WHERE id BETWEEN 21 AND 30; +""" + ) + cursor.nextset() + assert cursor.fetchone() == (11, "row 11") + + cursor.execute( + "SELECT * FROM test_cursor_discard_result WHERE id BETWEEN 31 AND 40" + ) + assert cursor.fetchone() == (31, "row 31") + + +def test_binary_prefix(): + # https://github.com/PyMySQL/mysqlclient/issues/494 + conn = connect(binary_prefix=True) + cursor = conn.cursor() + + cursor.execute("DROP TABLE IF EXISTS test_binary_prefix") + cursor.execute( + """\ +CREATE TABLE test_binary_prefix ( + id INTEGER NOT NULL AUTO_INCREMENT, + json JSON NOT NULL, + PRIMARY KEY (id) +) CHARSET=utf8mb4""" + ) + + cursor.executemany( + "INSERT INTO test_binary_prefix (id, json) VALUES (%(id)s, %(json)s)", + ({"id": 1, "json": "{}"}, {"id": 2, "json": "{}"}), + ) diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 00000000..fae28e81 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,56 @@ +import pytest +import MySQLdb.cursors +from configdb import connection_factory + + +_conns = [] +_tables = [] + + +def connect(**kwargs): + conn = connection_factory(**kwargs) + _conns.append(conn) + return conn + + +def teardown_function(function): + if _tables: + c = _conns[0] + cur = c.cursor() + for t in _tables: + cur.execute(f"DROP TABLE {t}") + cur.close() + del _tables[:] + + for c in _conns: + c.close() + del _conns[:] + + +def test_null(): + """Inserting NULL into non NULLABLE column""" + # https://github.com/PyMySQL/mysqlclient/issues/535 + table_name = "test_null" + conn = connect() + cursor = conn.cursor() + + cursor.execute(f"create table {table_name} (c1 int primary key)") + _tables.append(table_name) + + with pytest.raises(MySQLdb.IntegrityError): + cursor.execute(f"insert into {table_name} values (null)") + + +def test_duplicated_pk(): + """Inserting row with duplicated PK""" + # https://github.com/PyMySQL/mysqlclient/issues/535 + table_name = "test_duplicated_pk" + conn = connect() + cursor = conn.cursor() + + cursor.execute(f"create table {table_name} (c1 int primary key)") + _tables.append(table_name) + + cursor.execute(f"insert into {table_name} values (1)") + with pytest.raises(MySQLdb.IntegrityError): + cursor.execute(f"insert into {table_name} values (1)")