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.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 4b7cf394..00000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,13 +0,0 @@ -**IF YOU HAVE SOME TROUBLE, IT'S MAY NOT ISSUE OF THIS PROJECT. GO STACKOVERFLOW!!!** - -If you failed to build, go Stackoverflow. -If you failed to install, go Stackoverflow. -If you failed to connect to database, go Stackoverflow. - -FYI, MySQL Connector/C 6.1.10 has bug. see https://github.com/PyMySQL/mysqlclient-python/issues/169#issuecomment-299778504 - -Only when If you're sure it's PyMySQL's issue, report the complete steps to reproduce, from creating database. - -I don't have time to investigate your issue from an incomplete code snippet. - -See also: https://medium.com/@methane/why-you-must-not-ask-questions-on-github-issues-51d741d83fde 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 new file mode 100644 index 00000000..95a95ac3 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,15 @@ +name: Lint + +on: + push: + branches: ["main"] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - 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 new file mode 100644 index 00000000..cf784c78 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,108 @@ +name: Test + +on: + push: + branches: ["main"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + env: + PIP_NO_PYTHON_VERSION_WARNING: 1 + PIP_DISABLE_PIP_VERSION_CHECK: 1 + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + include: + - python-version: "3.12" + mariadb: 1 + steps: + - 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/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + cache-dependency-path: "requirements.txt" + allow-prereleases: true + + - name: Install mysqlclient + run: | + 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@v5 + + 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: "4.2.16" + 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: "3.12" + 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 new file mode 100644 index 00000000..f8dbf87a --- /dev/null +++ b/.github/workflows/windows.yaml @@ -0,0 +1,97 @@ +name: Build windows wheels + +on: + push: + branches: ["main", "ci"] + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + env: + CONNECTOR_VERSION: "3.4.1" + steps: + - name: Cache Connector + id: cache-connector + uses: actions/cache@v4 + with: + path: c:/mariadb-connector + key: mariadb-connector-c-${{ env.CONNECTOR_VERSION }}-win-2 + + - name: Download and Unzip Connector + if: steps.cache-connector.outputs.cache-hit != 'true' + shell: bash + run: | + curl -LO "https://downloads.mariadb.com/Connectors/c/connector-c-${CONNECTOR_VERSION}/mariadb-connector-c-${CONNECTOR_VERSION}-src.zip" + unzip "mariadb-connector-c-${CONNECTOR_VERSION}-src.zip" -d c:/ + mv "c:/mariadb-connector-c-${CONNECTOR_VERSION}-src" c:/mariadb-connector-src + + - name: make build directory + if: steps.cache-connector.outputs.cache-hit != 'true' + shell: cmd + working-directory: c:/mariadb-connector-src + run: | + mkdir build + + - name: cmake + if: steps.cache-connector.outputs.cache-hit != 'true' + shell: cmd + working-directory: c:/mariadb-connector-src/build + run: | + cmake -A x64 .. -DCMAKE_BUILD_TYPE=Release -DCLIENT_PLUGIN_DIALOG=static -DCLIENT_PLUGIN_SHA256_PASSWORD=static -DCLIENT_PLUGIN_CACHING_SHA2_PASSWORD=static -DDEFAULT_SSL_VERIFY_SERVER_CERT=0 + + - name: cmake build + if: steps.cache-connector.outputs.cache-hit != 'true' + shell: cmd + working-directory: c:/mariadb-connector-src/build + run: | + cmake --build . -j 8 --config Release + + - name: cmake install + if: steps.cache-connector.outputs.cache-hit != 'true' + shell: cmd + working-directory: c:/mariadb-connector-src/build + run: | + 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@v4 + with: + path: mysqlclient + + - name: Site Config + shell: bash + working-directory: mysqlclient + run: | + pwd + find . + cat <site.cfg + [options] + static = True + connector = C:/mariadb-connector + EOF + cat site.cfg + + - uses: actions/setup-python@v5 + - name: Install cibuildwheel + run: python -m pip install cibuildwheel + - name: Build wheels + working-directory: mysqlclient + env: + CIBW_PROJECT_REQUIRES_PYTHON: ">=3.9" + 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: | + python -m pip install build + python -m build -s -o dist + + - name: Upload Wheel + uses: actions/upload-artifact@v4 + with: + name: win-wheels + 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..75d7a389 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,18 @@ +version: 2 + +sphinx: + configuration: doc/conf.py + +build: + os: ubuntu-22.04 + tools: + python: "3.13" + + 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 017c3bb9..00000000 --- a/.travis.yml +++ /dev/null @@ -1,64 +0,0 @@ -dist: xenial -language: python - -# See aws s3 ls s3://travis-python-archives/binaries/ubuntu/16.04/x86_64/ -python: - - "nightly" - - "pypy3.5" - - "pypy2.7-5.10.0" - - "3.7" - - "3.6" - - "3.5" - - "2.7" - -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 - -matrix: - include: - - &django_py27 - python: "2.7" - install: - - pip install -U pip - - wget https://github.com/django/django/archive/1.11.18.tar.gz - - tar xf 1.11.18.tar.gz - - pip install django-1.11.18/ - - cp ci/test_mysql.py django-1.11.18/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-1.11.18/tests/ - - ./runtests.py --parallel=1 --settings=test_mysql - - - &django_py3 - <<: *django_py27 - python: "3.7" - -# vim: sw=2 ts=2 sts=2 diff --git a/HISTORY.rst b/HISTORY.rst index c5221a21..66470541 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,3 +1,215 @@ +====================== + What's new in 2.2.7 +====================== + +Release: 2025-01-10 + +* Add ``user``, ``host``, ``database``, and ``db`` attributes to ``Connection``. + opentelemetry-instrumentation-(dbapi|mysqlclient) use them. (#753) + +====================== + What's new in 2.2.6 +====================== + +Release: 2024-11-12 + +* MariaDB Connector/C 3.4 and MairaDB 11.4 enabled SSL and CA verification by default. + It affected 2.2.5 windows wheel. This release disables SSL and CA verification by default. (#731) + +* Add ``server_public_key_path`` option. It is needed to connect MySQL server with + ``sha256_password`` or ``caching_sha2_password`` authentication plugin without + secure connection. (#744) + +====================== + What's new in 2.2.5 +====================== + +Release: 2024-10-20 + +* (Windows wheel) Update MariaDB Connector/C to 3.4.1. #726 +* (Windows wheel) Build wheels for Python 3.13. #726 + +====================== + 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-11-17 + +* Add ``multistatement=True`` option. You can disable multi statement. (#500). +* Remove unnecessary bytes encoder which is remained for Django 1.11 + compatibility (#490). +* Deprecate ``passwd`` and ``db`` keyword. Use ``password`` and ``database`` + instead. (#488). +* Windows: Binary wheels are built with MariaDB Connector/C 3.2.4. (#508) +* ``set_character_set()`` sends ``SET NAMES`` query always. This means + all new connections send it too. This solves compatibility issues + when server and client library are different version. (#509) +* Remove ``escape()`` and ``escape_string()`` from ``MySQLdb`` package. + (#511) +* Add Python 3.10 support and drop Python 3.5 support. + +====================== + What's new in 2.0.3 +====================== + +Release: 2021-01-01 + +* Add ``-std=c99`` option to cflags by default for ancient compilers that doesn't + accept C99 by default. +* You can customize cflags and ldflags by setting ``MYSQLCLIENT_CFLAGS`` and + ``MYSQLCLIENT_LDFLAGS``. It overrides ``mysql_config``. + +====================== + What's new in 2.0.2 +====================== + +Release: 2020-12-10 + +* Windows: Update MariaDB Connector/C to 3.1.11. +* Optimize fetching many rows with DictCursor. + +====================== + What's new in 2.0.1 +====================== + +Release: 2020-07-03 + +* Fixed multithread safety issue in fetching row. +* Removed obsolete members from Cursor. (e.g. `messages`, `_warnings`, `_last_executed`) + +====================== + What's new in 2.0.0 +====================== + +Release: 2020-07-02 + +* Dropped Python 2 support +* Dropped Django 1.11 support +* Add context manager interface to Connection which closes the connection on ``__exit__``. +* Add ``ssl_mode`` option. + + +====================== + What's new in 1.4.6 +====================== + +Release: 2019-11-21 + +* The ``cp1252`` encoding is used when charset is "latin1". (#390) + +====================== + What's new in 1.4.5 +====================== + +Release: 2019-11-06 + +* The ``auth_plugin`` option is added. (#389) + + +====================== + What's new in 1.4.4 +====================== + +Release: 2019-08-12 + +* ``charset`` option is passed to ``mysql_options(mysql, MYSQL_SET_CHARSET_NAME, charset)`` + before ``mysql_real_connect`` is called. + This avoid extra ``SET NAMES `` query when creating connection. + + +====================== + What's new in 1.4.3 +====================== + +Release: 2019-08-09 + +* ``--static`` build supports ``libmariadbclient.a`` +* Try ``mariadb_config`` when ``mysql_config`` is not found +* 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) +* Fixed many circular references are created in ``Cursor.executemany()``. (#375) + + ====================== What's new in 1.4.2 ====================== @@ -191,7 +403,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) @@ -452,4 +664,3 @@ ursor.fetchXXXDict() methods raise DeprecationWarning cursor.begin() is making a brief reappearence. cursor.callproc() now works, with some limitations. - diff --git a/INSTALL.rst b/INSTALL.rst deleted file mode 100644 index f5be3f4e..00000000 --- a/INSTALL.rst +++ /dev/null @@ -1,146 +0,0 @@ -==================== -MySQLdb Installation -==================== - -.. contents:: -.. - -Prerequisites -------------- - -+ Python 2.7, 3.5 or higher - -+ setuptools - - * https://pypi.org/project/setuptools/ - -+ MySQL 5.5 or higher - - * https://www.mysql.com/downloads/ - - * MySQL 5.1 may work, but not supported. - -+ C compiler - - * Most free software-based systems already have this, usually gcc. - - * Most commercial UNIX platforms also come with a C compiler, or - you can also use gcc. - - * If you have some Windows flavor, you should use Windows SDK or - Visual C++. - - -Building and installing ------------------------ - -The setup.py script uses mysql_config to find all compiler and linker -options, and should work as is on any POSIX-like platform, so long as -mysql_config is in your path. - -Depending on which version of MySQL you have, you may have the option -of using three different client libraries. To select the client library, -edit the [options] section of site.cfg: - - static - if True, try to link against a static library; otherwise link - against dynamic libraries (default). - This option doesn't work for MySQL>5.6 since libmysqlclient - requires libstdc++. If you want to use, add `-lstdc++` to - mysql_config manually. - -If `/lib` is not added to `/etc/ld.so.conf`, `import _mysql` -doesn't work. To fix this, (1) set `LD_LIBRARY_PATH`, or (2) add -`-Wl,-rpath,/lib` to ldflags in your mysql_config. - -Finally, putting it together:: - - $ tar xz mysqlclient-1.3.6.tar.gz - $ cd mysqlclient-1.3.6 - $ # edit site.cfg if necessary - $ python setup.py build - $ sudo python setup.py install # or su first - - -Windows -....... - -I don't do Windows. However if someone provides me with a package for -Windows, I'll make it available. Don't ask me for help with Windows -because I can't help you. - -Generally, though, running setup.py is similar to above:: - - C:\...> python setup.py install - C:\...> python setup.py bdist_wininst - -The latter example should build a Windows installer package, if you -have the correct tools. In any event, you *must* have a C compiler. -Additionally, you have to set an environment variable (mysqlroot) -which is the path to your MySQL installation. In theory, it would be -possible to get this information out of the registry, but like I said, -I don't do Windows, but I'll accept a patch that does this. - -On Windows, you will definitely have to edit site.cfg since there is -no mysql_config in the MySQL package. - - -Binary Packages ---------------- - -I don't plan to make binary packages any more. However, if someone -contributes one, I will make it available. Several OS vendors have -their own packages available. - - -Red Hat Linux -............. - -MySQL-python is pre-packaged in Red Hat Linux 7.x and newer. This -includes Fedora Core and Red Hat Enterprise Linux. - - -Debian GNU/Linux -................ - -Packaged as `python-mysqldb`_:: - - # apt-get install python-mysqldb - -Or use Synaptic. - -.. _`python-mysqldb`: http://packages.debian.org/python-mysqldb - - -Ubuntu -...... - -Same as with Debian. - - -Gentoo Linux -............ - -Packaged as `mysql-python`_. :: - - # emerge sync - # emerge mysql-python - # emerge zmysqlda # if you use Zope - -.. _`mysql-python`: https://packages.gentoo.org/packages/search?q=mysql-python - - -BSD -... - -MySQL-python is a ported package in FreeBSD, NetBSD, and OpenBSD, -although the name may vary to match OS conventions. - - -License -------- - -GPL or the original license based on Python 1.5.2's license. - - -:Author: Andy Dustman diff --git a/MANIFEST.in b/MANIFEST.in index 415a995a..58a996de 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,13 +1,7 @@ recursive-include doc *.rst recursive-include tests *.py include doc/conf.py -include MANIFEST.in include HISTORY.rst -include INSTALL.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 491cc75e..3f9ff8bb 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,20 @@ .PHONY: build build: - python3 setup.py build_ext -if + python setup.py build_ext -if +.PHONY: doc +doc: + pip install . + pip install sphinx + cd doc && make html .PHONY: clean clean: find . -name '*.pyc' -delete find . -name '__pycache__' -delete - rm *.so - python3 setup.py clean + rm -rf build + +.PHONY: check +check: + ruff *.py src ci + black *.py src ci diff --git a/MySQLdb/__init__.py b/MySQLdb/__init__.py deleted file mode 100644 index d4c7fa56..00000000 --- a/MySQLdb/__init__.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -MySQLdb - A DB API v2.0 compatible interface to MySQL. - -This package is a wrapper around _mysql, which mostly implements the -MySQL C API. - -connect() -- connects to server - -See the C API specification and the MySQL documentation for more info -on other items. - -For information on how MySQLdb handles type conversion, see the -MySQLdb.converters module. -""" - -from MySQLdb.release import __version__, version_info, __author__ - -from . import _mysql - -if version_info != _mysql.version_info: - raise ImportError("this is MySQLdb version %s, but _mysql is version %r" % - (version_info, _mysql.version_info)) - -threadsafety = 1 -apilevel = "2.0" -paramstyle = "format" - -from ._mysql import * -from MySQLdb.compat import PY2 -from MySQLdb.constants import FIELD_TYPE -from MySQLdb.times import Date, Time, Timestamp, \ - DateFromTicks, TimeFromTicks, TimestampFromTicks - -try: - frozenset -except NameError: - from sets import ImmutableSet as frozenset - -class DBAPISet(frozenset): - """A special type of set for which A == x is true if A is a - DBAPISet and x is a member of that set.""" - - def __eq__(self, other): - if isinstance(other, DBAPISet): - return not self.difference(other) - return other in self - - -STRING = DBAPISet([FIELD_TYPE.ENUM, FIELD_TYPE.STRING, - FIELD_TYPE.VAR_STRING]) -BINARY = DBAPISet([FIELD_TYPE.BLOB, FIELD_TYPE.LONG_BLOB, - FIELD_TYPE.MEDIUM_BLOB, FIELD_TYPE.TINY_BLOB]) -NUMBER = DBAPISet([FIELD_TYPE.DECIMAL, FIELD_TYPE.DOUBLE, FIELD_TYPE.FLOAT, - FIELD_TYPE.INT24, FIELD_TYPE.LONG, FIELD_TYPE.LONGLONG, - FIELD_TYPE.TINY, FIELD_TYPE.YEAR, FIELD_TYPE.NEWDECIMAL]) -DATE = DBAPISet([FIELD_TYPE.DATE]) -TIME = DBAPISet([FIELD_TYPE.TIME]) -TIMESTAMP = DBAPISet([FIELD_TYPE.TIMESTAMP, FIELD_TYPE.DATETIME]) -DATETIME = TIMESTAMP -ROWID = DBAPISet() - -def test_DBAPISet_set_equality(): - assert STRING == STRING - -def test_DBAPISet_set_inequality(): - assert STRING != NUMBER - -def test_DBAPISet_set_equality_membership(): - assert FIELD_TYPE.VAR_STRING == STRING - -def test_DBAPISet_set_inequality_membership(): - assert FIELD_TYPE.DATE != STRING - -if PY2: - def Binary(x): - return bytearray(x) -else: - def Binary(x): - return bytes(x) - -def Connect(*args, **kwargs): - """Factory function for connections.Connection.""" - from MySQLdb.connections import Connection - return Connection(*args, **kwargs) - -connect = Connection = Connect - -__all__ = [ 'BINARY', 'Binary', 'Connect', 'Connection', 'DATE', - 'Date', 'Time', 'Timestamp', 'DateFromTicks', 'TimeFromTicks', - 'TimestampFromTicks', 'DataError', 'DatabaseError', 'Error', - 'FIELD_TYPE', 'IntegrityError', 'InterfaceError', 'InternalError', - 'MySQLError', 'NUMBER', 'NotSupportedError', 'DBAPISet', - 'OperationalError', 'ProgrammingError', 'ROWID', 'STRING', 'TIME', - 'TIMESTAMP', 'Warning', 'apilevel', 'connect', 'connections', - 'constants', 'converters', 'cursors', 'debug', 'escape', - 'escape_string', 'get_client_info', - 'paramstyle', 'string_literal', 'threadsafety', 'version_info'] - diff --git a/MySQLdb/compat.py b/MySQLdb/compat.py deleted file mode 100644 index f8d98ac2..00000000 --- a/MySQLdb/compat.py +++ /dev/null @@ -1,14 +0,0 @@ -import sys - -if sys.version_info[0] == 2: - PY2 = True - unicode = unicode - unichr = unichr - long = long - StandardError = StandardError -else: - PY2 = False - unicode = str - unichr = chr - long = int - StandardError = Exception diff --git a/MySQLdb/constants/__init__.py b/MySQLdb/constants/__init__.py deleted file mode 100644 index 3e774cd9..00000000 --- a/MySQLdb/constants/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__all__ = ['CR', 'FIELD_TYPE','CLIENT','ER','FLAG'] diff --git a/README.md b/README.md index cd91383d..e679b533 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,100 @@ # mysqlclient -[![Build Status](https://secure.travis-ci.org/PyMySQL/mysqlclient-python.png)](http://travis-ci.org/PyMySQL/mysqlclient-python) +This project is a fork of [MySQLdb1](https://github.com/farcepest/MySQLdb1). +This project adds Python 3 support and fixed many bugs. -This is a fork of [MySQLdb1](https://github.com/farcepest/MySQLdb1). +* PyPI: https://pypi.org/project/mysqlclient/ +* GitHub: https://github.com/PyMySQL/mysqlclient -This project adds Python 3 support and bug fixes. -I hope this fork is merged back to MySQLdb1 like distribute was merged back to setuptools. -## Install - -### Prerequisites +## Support -You may need to install the Python and MySQL development headers and libraries like so: +**Do Not use Github Issue Tracker to ask help. OSS Maintainer is not free tech support** -* `sudo apt-get install python-dev default-libmysqlclient-dev` # Debian / Ubuntu -* `sudo yum install python-devel mysql-devel` # Red Hat / CentOS -* `brew install mysql-connector-c` # macOS (Homebrew) (Currently, it has bug. See below) +When your question looks relating to Python rather than MySQL/MariaDB: -On Windows, there are binary wheels you can install without MySQLConnector/C or MSVC. +* Python mailing list [python-list](https://mail.python.org/mailman/listinfo/python-list) +* Slack [pythondev.slack.com](https://pyslackers.com/web/slack) -#### Note on Python 3 : if you are using python3 then you need to install python3-dev using the following command : +Or when you have question about MySQL/MariaDB: -`sudo apt-get install python3-dev` # debian / Ubuntu +* [MySQL Support](https://dev.mysql.com/support/) +* [Getting Help With MariaDB](https://mariadb.com/kb/en/getting-help-with-mariadb/) -`sudo yum install python3-devel ` # Red Hat / CentOS -#### **Note about bug of MySQL Connector/C on macOS** - -See also: https://bugs.mysql.com/bug.php?id=86971 +## Install -Versions of MySQL Connector/C may have incorrect default configuration options that cause compilation errors when `mysqlclient-python` is installed. (As of November 2017, this is known to be true for homebrew's `mysql-connector-c` and [official package](https://dev.mysql.com/downloads/connector/c/)) +### Windows -Modification of `mysql_config` resolves these issues as follows. +Building mysqlclient on Windows is very hard. +But there are some binary wheels you can install easily. -Change +If binary wheels do not exist for your version of Python, it may be possible to +build from source, but if this does not work, **do not come asking for support.** +To build from source, download the +[MariaDB C Connector](https://mariadb.com/downloads/#connectors) and install +it. It must be installed in the default location +(usually "C:\Program Files\MariaDB\MariaDB Connector C" or +"C:\Program Files (x86)\MariaDB\MariaDB Connector C" for 32-bit). If you +build the connector yourself or install it in a different location, set the +environment variable `MYSQLCLIENT_CONNECTOR` before installing. Once you have +the connector installed and an appropriate version of Visual Studio for your +version of Python: ``` -# on macOS, on or about line 112: -# Create options -libs="-L$pkglibdir" -libs="$libs -l " +$ pip install mysqlclient ``` -to +### macOS (Homebrew) +Install MySQL and mysqlclient: + +```bash +$ # Assume you are activating Python 3 venv +$ brew install mysql pkg-config +$ pip install mysqlclient ``` -# Create options -libs="-L$pkglibdir" -libs="$libs -lmysqlclient -lssl -lcrypto" + +If you don't want to install MySQL server, you can use mysql-client instead: + +```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 ``` -An improper ssl configuration may also create issues; see, e.g, `brew info openssl` for details on macOS. +### Linux -### Install from PyPI +**Note that this is a basic step. I can not support complete step for build for all +environment. If you can see some error, you should fix it by yourself, or ask for +support in some user forum. Don't file a issue on the issue tracker.** -`pip install mysqlclient` +You may need to install the Python 3 and MySQL development headers and libraries like so: -NOTE: Wheels for Windows may be not released with source package. You should pin version -in your `requirements.txt` to avoid trying to install newest source package. +* `$ 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: -### Install from source +``` +$ pip install mysqlclient +``` + +### Customize build (POSIX) + +mysqlclient uses `pkg-config --cflags --ldflags mysqlclient` by default for finding +compiler/linker flags. -1. Download source by `git clone` or [zipfile](https://github.com/PyMySQL/mysqlclient-python/archive/master.zip). -2. Customize `site.cfg` -3. `python setup.py install` +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 +``` ### Documentation Documentation is hosted on [Read The Docs](https://mysqlclient.readthedocs.io/) - 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 d24f30f1..498be7cf 100644 --- a/ci/test_mysql.py +++ b/ci/test_mysql.py @@ -13,33 +13,31 @@ # file for each of the backends you test against. DATABASES = { - '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', - }, + "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"}, }, - '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', - }, - } } SECRET_KEY = "django_tests_secret_key" # Use a fast hasher to speed up tests. PASSWORD_HASHERS = [ - 'django.contrib.auth.hashers.MD5PasswordHasher', + "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 00000000..014486d2 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "src/MySQLdb/constants/*" diff --git a/doc/MySQLdb.constants.rst b/doc/MySQLdb.constants.rst index 5c9a5389..a803e3e5 100644 --- a/doc/MySQLdb.constants.rst +++ b/doc/MySQLdb.constants.rst @@ -48,4 +48,3 @@ constants Package :members: :undoc-members: :show-inheritance: - diff --git a/doc/MySQLdb.rst b/doc/MySQLdb.rst index ac690900..5e6791d5 100644 --- a/doc/MySQLdb.rst +++ b/doc/MySQLdb.rst @@ -9,37 +9,49 @@ 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:`MySQLdb._mysql` Module +---------------------------- + +.. automodule:: MySQLdb._mysql + :members: + :undoc-members: + +:mod:`MySQLdb._exceptions` Module +--------------------------------- + +.. automodule:: MySQLdb._exceptions + :members: + :undoc-members: + Subpackages ----------- diff --git a/doc/_exceptions.rst b/doc/_exceptions.rst deleted file mode 100644 index b509338c..00000000 --- a/doc/_exceptions.rst +++ /dev/null @@ -1,7 +0,0 @@ -_exceptions Module -======================== - -.. automodule:: MySQLdb._exceptions - :members: - :undoc-members: - :show-inheritance: diff --git a/doc/_mysql.rst b/doc/_mysql.rst deleted file mode 100644 index cf464b4f..00000000 --- a/doc/_mysql.rst +++ /dev/null @@ -1,7 +0,0 @@ -_mysql Module -============= - -.. automodule:: MySQLdb._mysql - :members: - :undoc-members: - :show-inheritance: diff --git a/doc/conf.py b/doc/conf.py index b9f58bd7..e06003ff 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # MySQLdb documentation build configuration file, created by # sphinx-quickstart on Sun Oct 07 19:36:17 2012. @@ -11,214 +10,218 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os +# skip flake8 and black for this file +# flake8: noqa +import sys +import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('..')) +# sys.path.insert(0, os.path.abspath("..")) # -- 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' +# needs_sphinx = "1.0" # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc'] +extensions = ["sphinx.ext.autodoc"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = "utf-8-sig" # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'MySQLdb' -copyright = u'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 # built documents. # # The short X.Y version. -version = '1.2' +version = "1.2" # The full version, including alpha/beta/rc tags. -release = '1.2.4b4' +release = "1.2.4b4" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = "" # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = "%B %d, %Y" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # 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 # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = "%b %d, %Y" # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = "" # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'MySQLdbdoc' +htmlhelp_basename = "MySQLdbdoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'MySQLdb.tex', u'MySQLdb Documentation', - u'Andy Dustman', 'manual'), + ("index", "MySQLdb.tex", "MySQLdb Documentation", "Andy Dustman", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'mysqldb', u'MySQLdb Documentation', - [u'Andy Dustman'], 1) -] +man_pages = [("index", "mysqldb", "MySQLdb Documentation", ["Andy Dustman"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -227,16 +230,22 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'MySQLdb', u'MySQLdb Documentation', - u'Andy Dustman', 'MySQLdb', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "MySQLdb", + "MySQLdb Documentation", + "Andy Dustman", + "MySQLdb", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' diff --git a/doc/modules.rst b/doc/modules.rst deleted file mode 100644 index 998ac460..00000000 --- a/doc/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -MySQLdb -======= - -.. toctree:: - :maxdepth: 4 - - MySQLdb diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 00000000..48319f03 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,2 @@ +sphinx~=8.0 +sphinx-rtd-theme~=3.0.0 diff --git a/doc/user_guide.rst b/doc/user_guide.rst index 405b444e..391b162f 100644 --- a/doc/user_guide.rst +++ b/doc/user_guide.rst @@ -8,13 +8,13 @@ MySQLdb User's Guide Introduction ------------ -MySQLdb is an interface to the popular MySQL -database server that provides the Python database API. +MySQLdb is an interface to the popular MySQL or MariaDB +database servers that provides the Python database API. Installation ------------ -The ``README`` file has complete installation instructions. +The `README `_ file has complete installation instructions. MySQLdb._mysql @@ -125,19 +125,19 @@ We haven't even begun to touch upon all the parameters ``connect()`` can take. For this reason, I prefer to use keyword parameters:: db=_mysql.connect(host="localhost",user="joebob", - passwd="moonpie",db="thangs") + password="moonpie",database="thangs") This does exactly what the last example did, but is arguably easier to read. But since the default host is "localhost", and if your login name really was "joebob", you could shorten it to this:: - db=_mysql.connect(passwd="moonpie",db="thangs") + db=_mysql.connect(password="moonpie",database="thangs") UNIX sockets and named pipes don't work over a network, so if you specify a host other than localhost, TCP will be used, and you can specify an odd port if you need to (the default port is 3306):: - db=_mysql.connect(host="outhouse",port=3307,passwd="moonpie",db="thangs") + db=_mysql.connect(host="outhouse",port=3307,password="moonpie",database="thangs") If you really had to, you could connect to the local host with TCP by specifying the full host name, or 127.0.0.1. @@ -145,7 +145,7 @@ specifying the full host name, or 127.0.0.1. Generally speaking, putting passwords in your code is not such a good idea:: - db=_mysql.connect(host="outhouse",db="thangs",read_default_file="~/.my.cnf") + db=_mysql.connect(host="outhouse",database="thangs",read_default_file="~/.my.cnf") This does what the previous example does, but gets the username and password and other parameters from ~/.my.cnf (UNIX-like systems). Read @@ -277,10 +277,10 @@ connect(parameters...) user user to authenticate as. Default: current effective user. - passwd + password password to authenticate with. Default: no password. - db + database database to use. Default: no default database. port @@ -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 @@ -357,6 +373,18 @@ connect(parameters...) *This must be a keyword parameter.* + ssl_mode + If present, specify the security settings for the + connection to the server. For more information on ssl_mode, + see the MySQL documentation. Only one of 'DISABLED', + 'PREFERRED', 'REQUIRED', 'VERIFY_CA', 'VERIFY_IDENTITY' + can be specified. + + If not present, the session ssl_mode will be unchanged, + but in version 5.7 and later, the default is PREFERRED. + + *This must be a keyword parameter.* + ssl This parameter takes a dictionary or mapping, where the keys are parameter names used by the mysql_ssl_set_ MySQL @@ -365,6 +393,21 @@ connect(parameters...) an exception is raised. *This must be a keyword parameter.* + server_public_key_path + specifies path to a RSA public key used by caching sha2 password authentication. + See https://dev.mysql.com/doc/refman/9.0/en/caching-sha2-pluggable-authentication.html + + local_infile + sets ``MYSQL_OPT_LOCAL_INFILE`` in ``mysql_options()`` enabling LOAD LOCAL INFILE from any path; zero disables; + + *This must be a keyword parameter.* + + local_infile_dir + sets ``MYSQL_OPT_LOAD_DATA_LOCAL_DIR`` in ``mysql_options()`` enabling LOAD LOCAL INFILE from any path; + if ``local_infile`` is set to ``True`` then this is ignored; + + *This must be a keyword parameter.* + .. _mysql_ssl_set: http://dev.mysql.com/doc/refman/en/mysql-ssl-set.html @@ -499,7 +542,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 @@ -546,10 +589,10 @@ nextset() Some examples ............. -The ``connect()`` method works nearly the same as with `_mysql`_:: +The ``connect()`` method works nearly the same as with `MySQLDB._mysql`_:: import MySQLdb - db=MySQLdb.connect(passwd="moonpie",db="thangs") + db=MySQLdb.connect(password="moonpie",database="thangs") To perform a query, you first need a cursor, and then you can execute queries on it:: @@ -662,10 +705,9 @@ CursorDictRowsMixIn Cursor The default cursor class. This class is composed of - ``CursorWarningMixIn``, ``CursorStoreResultMixIn``, - ``CursorTupleRowsMixIn,`` and ``BaseCursor``, i.e. it raises - ``Warning``, uses ``mysql_store_result()``, and returns rows as - tuples. + ``CursorStoreResultMixIn``, ``CursorTupleRowsMixIn``, and + ``BaseCursor``, i.e. uses ``mysql_store_result()`` and returns + rows as tuples. DictCursor Like ``Cursor`` except it returns rows as dictionaries. diff --git a/metadata.cfg b/metadata.cfg deleted file mode 100644 index c8112de2..00000000 --- a/metadata.cfg +++ /dev/null @@ -1,42 +0,0 @@ -[metadata] -version: 1.4.2.post1 -version_info: (1,4,2,'post',1) -description: Python interface to MySQL -author: Inada Naoki -author_email: songofacandy@gmail.com -license: GPL -platforms: ALL -url: https://github.com/PyMySQL/mysqlclient-python -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 :: 2 - Programming Language :: Python :: 2.7 - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Topic :: Database - Topic :: Database :: Database Engines/Servers -py_modules: - MySQLdb._exceptions - MySQLdb.compat - 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..d786f336 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "mysqlclient" +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 or later (GPLv2+)"} +keywords = ["MySQL"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Other Environment", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", + "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", + "Programming Language :: Python :: 3.13", + "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 d1029962..9a1d26b8 100644 --- a/setup.py +++ b/setup.py @@ -1,22 +1,169 @@ #!/usr/bin/env python - import os -import io +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", "perconaserverclient"] + 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", "mysql"), + 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 io.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" -setuptools.setup(**metadata) +setuptools.setup(ext_modules=ext_modules) diff --git a/setup_common.py b/setup_common.py deleted file mode 100644 index 03c39bb7..00000000 --- a/setup_common.py +++ /dev/null @@ -1,37 +0,0 @@ -try: - # Python 2.x - from ConfigParser import SafeConfigParser -except ImportError: - # Python 3.x - 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 %s for option %s" % (value, option)) - -def create_release_file(metadata): - rel = open("MySQLdb/release.py",'w') - rel.write(""" -__author__ = "%(author)s <%(author_email)s>" -version_info = %(version_info)s -__version__ = "%(version)s" -""" % metadata) - rel.close() diff --git a/setup_posix.py b/setup_posix.py deleted file mode 100644 index 2b594102..00000000 --- a/setup_posix.py +++ /dev/null @@ -1,113 +0,0 @@ -import os, sys -try: - from ConfigParser import SafeConfigParser -except ImportError: - from configparser import ConfigParser as SafeConfigParser - -# 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): - from os import popen - - f = popen("%s --%s" % (_mysql_config_path, what)) - data = f.read().strip().split() - ret = f.close() - if ret: - if ret/256: - data = [] - if ret/256 > 1: - raise EnvironmentError("%s not found" % (_mysql_config_path,)) - 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'] - - 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 = 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'))] - - removable_compile_args = ('-I', '-L', '-l') - extra_compile_args = [i.replace("%", "%%") for i in mysql_config("cflags") - if i[:2] not in removable_compile_args] - - # Copy the arch flags for linking as well - for i in range(len(extra_compile_args)): - if extra_compile_args[i] == '-arch': - extra_link_args += ['-arch', extra_compile_args[i + 1]] - - include_dirs = [dequote(i[2:]) - for i in mysql_config('include') if i.startswith('-I')] - - 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) - - 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++' - - 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 cb2cbab0..00000000 --- a/setup_windows.py +++ /dev/null @@ -1,52 +0,0 @@ -import os, sys -from distutils.msvccompiler import get_build_version - - -def get_config(): - from setup_common import get_metadata_and_options, enabled, create_release_file - - metadata, options = get_metadata_and_options() - - connector = options["connector"] - - extra_objects = [] - - # client = "mysqlclient" - client = "mariadbclient" - - vcversion = int(get_build_version()) - if client == "mariadbclient": - library_dirs = [os.path.join(connector, 'lib', 'mariadb')] - libraries = ['kernel32', 'advapi32', 'wsock32', 'shlwapi', 'Ws2_32', client ] - include_dirs = [os.path.join(connector, 'include', 'mariadb')] - else: - library_dirs = [os.path.join(connector, r'lib\vs%d' % vcversion), - os.path.join(connector, "lib")] - libraries = ['kernel32', 'advapi32', 'wsock32', client ] - include_dirs = [os.path.join(connector, r'include')] - - extra_compile_args = ['/Zl', '/D_CRT_SECURE_NO_WARNINGS' ] - 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_compile_args = extra_compile_args, - 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 6b4596a4..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 = C:\Program Files (x86)\MySQL\MySQL Connector C 6.1 +connector = diff --git a/src/MySQLdb/__init__.py b/src/MySQLdb/__init__.py new file mode 100644 index 00000000..153bbdfe --- /dev/null +++ b/src/MySQLdb/__init__.py @@ -0,0 +1,168 @@ +""" +MySQLdb - A DB API v2.0 compatible interface to MySQL. + +This package is a wrapper around _mysql, which mostly implements the +MySQL C API. + +connect() -- connects to server + +See the C API specification and the MySQL documentation for more info +on other items. + +For information on how MySQLdb handles type conversion, see the +MySQLdb.converters module. +""" + +from .release import version_info +from . import _mysql + +if version_info != _mysql.version_info: + raise ImportError( + f"this is MySQLdb version {version_info}, " + f"but _mysql is version {_mysql.version_info!r}\n" + f"_mysql: {_mysql.__file__!r}" + ) + + +from ._mysql import ( + NotSupportedError, + OperationalError, + get_client_info, + ProgrammingError, + Error, + InterfaceError, + debug, + IntegrityError, + string_literal, + MySQLError, + DataError, + DatabaseError, + InternalError, + Warning, +) +from MySQLdb.constants import FIELD_TYPE +from MySQLdb.times import ( + Date, + Time, + Timestamp, + DateFromTicks, + TimeFromTicks, + TimestampFromTicks, +) + +threadsafety = 1 +apilevel = "2.0" +paramstyle = "format" + + +class DBAPISet(frozenset): + """A special type of set for which A == x is true if A is a + DBAPISet and x is a member of that set.""" + + def __eq__(self, other): + if isinstance(other, DBAPISet): + return not self.difference(other) + return other in self + + +STRING = DBAPISet([FIELD_TYPE.ENUM, FIELD_TYPE.STRING, FIELD_TYPE.VAR_STRING]) +BINARY = DBAPISet( + [ + FIELD_TYPE.BLOB, + FIELD_TYPE.LONG_BLOB, + FIELD_TYPE.MEDIUM_BLOB, + FIELD_TYPE.TINY_BLOB, + ] +) +NUMBER = DBAPISet( + [ + FIELD_TYPE.DECIMAL, + FIELD_TYPE.DOUBLE, + FIELD_TYPE.FLOAT, + FIELD_TYPE.INT24, + FIELD_TYPE.LONG, + FIELD_TYPE.LONGLONG, + FIELD_TYPE.TINY, + FIELD_TYPE.YEAR, + FIELD_TYPE.NEWDECIMAL, + ] +) +DATE = DBAPISet([FIELD_TYPE.DATE]) +TIME = DBAPISet([FIELD_TYPE.TIME]) +TIMESTAMP = DBAPISet([FIELD_TYPE.TIMESTAMP, FIELD_TYPE.DATETIME]) +DATETIME = TIMESTAMP +ROWID = DBAPISet() + + +def test_DBAPISet_set_equality(): + assert STRING == STRING + + +def test_DBAPISet_set_inequality(): + assert STRING != NUMBER + + +def test_DBAPISet_set_equality_membership(): + assert FIELD_TYPE.VAR_STRING == STRING + + +def test_DBAPISet_set_inequality_membership(): + assert FIELD_TYPE.DATE != STRING + + +def Binary(x): + return bytes(x) + + +def Connect(*args, **kwargs): + """Factory function for connections.Connection.""" + from MySQLdb.connections import Connection + + return Connection(*args, **kwargs) + + +connect = Connection = Connect + +__all__ = [ + "BINARY", + "Binary", + "Connect", + "Connection", + "DATE", + "Date", + "Time", + "Timestamp", + "DateFromTicks", + "TimeFromTicks", + "TimestampFromTicks", + "DataError", + "DatabaseError", + "Error", + "FIELD_TYPE", + "IntegrityError", + "InterfaceError", + "InternalError", + "MySQLError", + "NUMBER", + "NotSupportedError", + "DBAPISet", + "OperationalError", + "ProgrammingError", + "ROWID", + "STRING", + "TIME", + "TIMESTAMP", + "Warning", + "apilevel", + "connect", + "connections", + "constants", + "converters", + "cursors", + "debug", + "get_client_info", + "paramstyle", + "string_literal", + "threadsafety", + "version_info", +] diff --git a/MySQLdb/_exceptions.py b/src/MySQLdb/_exceptions.py similarity index 86% rename from MySQLdb/_exceptions.py rename to src/MySQLdb/_exceptions.py index 0f14f3bd..a5aca7e1 100644 --- a/MySQLdb/_exceptions.py +++ b/src/MySQLdb/_exceptions.py @@ -4,38 +4,49 @@ https://www.python.org/dev/peps/pep-0249/ """ -from .compat import StandardError -class MySQLError(StandardError): +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 @@ -44,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 80% rename from MySQLdb/_mysql.c rename to src/MySQLdb/_mysql.c index cef196d1..cd95b641 100644 --- a/MySQLdb/_mysql.c +++ b/src/MySQLdb/_mysql.c @@ -25,25 +25,38 @@ 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) || \ + (MYSQL_VERSION_ID >= 50636 && MYSQL_VERSION_ID <= 50699) || \ + (MYSQL_VERSION_ID >= 50711 && MYSQL_VERSION_ID <= 50799) || \ + (MYSQL_VERSION_ID >= 80000)) && \ + !defined(MARIADB_BASE_VERSION) && !defined(MARIADB_VERSION_ID) +#define HAVE_ENUM_MYSQL_OPT_SSL_MODE +#endif + +#if defined(MARIADB_VERSION_ID) && MARIADB_VERSION_ID >= 100403 || \ + !defined(MARIADB_VERSION_ID) && MYSQL_VERSION_ID >= 50723 +#define HAVE_MYSQL_SERVER_PUBLIC_KEY +#endif + +#if !defined(MARIADB_VERSION_ID) && MYSQL_VERSION_ID >= 80021 +#define HAVE_MYSQL_OPT_LOCAL_INFILE_DIR #endif #define PY_SSIZE_T_CLEAN 1 #include "Python.h" -#if PY_MAJOR_VERSION >= 3 -#define IS_PY3K -#define PyInt_Type PyLong_Type -#define PyInt_FromString PyLong_FromString -#define PyInt_FromLong(n) PyLong_FromLong(n) -#define PyInt_Check(n) PyLong_Check(n) -#define PyInt_AS_LONG(n) PyLong_AS_LONG(n) -#define PyString_FromString(s) PyUnicode_FromString(s) + +#if PY_MAJOR_VERSION == 2 +#error "Python 2 is not supported" #endif #include "bytesobject.h" @@ -68,7 +81,8 @@ static PyObject *_mysql_NotSupportedError; typedef struct { PyObject_HEAD MYSQL connection; - int open; + bool open; + bool reconnect; PyObject *converter; } _mysql_ConnectionObject; @@ -177,6 +191,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 @@ -200,8 +215,8 @@ _mysql_Exception(_mysql_ConnectionObject *c) e = _mysql_OperationalError; break; } - PyTuple_SET_ITEM(t, 0, PyInt_FromLong((long)merr)); - PyTuple_SET_ITEM(t, 1, PyString_FromString(mysql_error(&(c->connection)))); + PyTuple_SET_ITEM(t, 0, PyLong_FromLong((long)merr)); + PyTuple_SET_ITEM(t, 1, PyUnicode_FromString(mysql_error(&(c->connection)))); PyErr_SetObject(e, t); Py_DECREF(t); return NULL; @@ -217,6 +232,9 @@ _get_encoding(MYSQL *mysql) if (strncmp(utf8, cs.csname, 4) == 0) { // utf8, utf8mb3, utf8mb4 return utf8; } + else if (strncmp("latin1", cs.csname, 6) == 0) { + return "cp1252"; + } else if (strncmp("koi8r", cs.csname, 5) == 0) { return "koi8_r"; } @@ -283,7 +301,7 @@ _mysql_ResultObject_Initialize( fields = mysql_fetch_fields(result); for (i=0; iconverter = NULL; - self->open = 0; + self->open = false; + self->reconnect = false; if (!PyArg_ParseTupleAndKeywords(args, kwargs, - "|ssssisOiiisssiOiii:connect", + "|ssssisOiiisssiOsiiissss:connect", kwlist, &host, &user, &passwd, &db, &port, &unix_socket, &conv, @@ -419,30 +468,61 @@ _mysql_ConnectionObject_Initialize( &compress, &named_pipe, &init_command, &read_default_file, &read_default_group, - &client_flag, &ssl, + &client_flag, &ssl, &ssl_mode, &local_infile, &read_timeout, - &write_timeout + &write_timeout, + &charset, + &auth_plugin, + &server_public_key_path, + &local_infile_dir )) return -1; -#ifdef IS_PY3K +#ifndef HAVE_MYSQL_SERVER_PUBLIC_KEY + if (server_public_key_path) { + PyErr_SetString(_mysql_NotSupportedError, "server_public_key_path is not supported"); + return -1; + } +#endif + +#ifndef HAVE_MYSQL_OPT_LOCAL_INFILE_DIR + if (local_infile_dir) { + PyErr_SetString(_mysql_NotSupportedError, "local_infile_dir is not supported"); + return -1; + } +#endif + // For compatibility with PyPy, we need to keep strong reference + // to unicode objects until we use UTF8. #define _stringsuck(d,t,s) {t=PyMapping_GetItemString(s,#d);\ if(t){d=PyUnicode_AsUTF8(t);ssl_keepref[n_ssl_keepref++]=t;}\ PyErr_Clear();} -#else -#define _stringsuck(d,t,s) {t=PyMapping_GetItemString(s,#d);\ - if(t){d=PyString_AsString(t);ssl_keepref[n_ssl_keepref++]=t;}\ - PyErr_Clear();} -#endif + 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) { + if ((ssl_mode_num = _get_ssl_mode_num(ssl_mode)) <= 0) { + PyErr_SetString(_mysql_NotSupportedError, "Unknown ssl_mode specification"); + return -1; + } + ssl_mode_set = 1; } conn = mysql_init(&(self->connection)); @@ -450,8 +530,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, @@ -484,21 +564,64 @@ _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); + } + for (int i=0 ; iconnection), host, user, passwd, db, - port, unix_socket, client_flag); +#ifdef HAVE_ENUM_MYSQL_OPT_SSL_MODE + if (ssl_mode_set) { + 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 + // And MariDB 11.4 changed the default value of MYSQL_OPT_SSL_ENFORCE and + // MYSQL_OPT_SSL_VERIFY_SERVER_CERT to 1. + // https://github.com/mariadb-corporation/mariadb-connector-c/commit/8dffd56936df3d03eeccf47904773860a0cdeb57 + // We emulate the ssl_mode and old behavior. + my_bool my_true = 1; + my_bool my_false = 0; + if (ssl_mode_num >= SSLMODE_REQUIRED) { + mysql_options(&(self->connection), MYSQL_OPT_SSL_ENFORCE, (void *)&my_true); + } else { + mysql_options(&(self->connection), MYSQL_OPT_SSL_ENFORCE, (void *)&my_false); + } + if (ssl_mode_num >= SSLMODE_VERIFY_CA) { + mysql_options(&(self->connection), MYSQL_OPT_SSL_VERIFY_SERVER_CERT, (void *)&my_true); + } else { + mysql_options(&(self->connection), MYSQL_OPT_SSL_VERIFY_SERVER_CERT, (void *)&my_false); + } +#endif - Py_END_ALLOW_THREADS ; + if (charset) { + mysql_options(&(self->connection), MYSQL_SET_CHARSET_NAME, charset); + } + if (auth_plugin) { + mysql_options(&(self->connection), MYSQL_DEFAULT_AUTH, auth_plugin); + } +#ifdef HAVE_MYSQL_SERVER_PUBLIC_KEY + if (server_public_key_path) { + mysql_options(&(self->connection), MYSQL_SERVER_PUBLIC_KEY, server_public_key_path); + } +#endif - if (ssl) { - int i; - for (i=0; iconnection), MYSQL_OPT_LOAD_DATA_LOCAL_DIR, local_infile_dir); } +#endif + + Py_BEGIN_ALLOW_THREADS + conn = mysql_real_connect(&(self->connection), host, user, passwd, db, + port, unix_socket, client_flag); + Py_END_ALLOW_THREADS if (!conn) { _mysql_Exception(self); @@ -535,10 +658,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\ @@ -625,7 +748,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; } @@ -644,7 +767,7 @@ _mysql_ConnectionObject_affected_rows( check_connection(self); ret = mysql_affected_rows(&(self->connection)); if (ret == (my_ulonglong)-1) - return PyInt_FromLong(-1); + return PyLong_FromLong(-1); return PyLong_FromUnsignedLongLong(ret); } @@ -776,7 +899,7 @@ _mysql_ConnectionObject_next_result( err = mysql_next_result(&(self->connection)); Py_END_ALLOW_THREADS if (err > 0) return _mysql_Exception(self); - return PyInt_FromLong(err); + return PyLong_FromLong(err); } @@ -799,7 +922,7 @@ _mysql_ConnectionObject_set_server_option( err = mysql_set_server_option(&(self->connection), flags); Py_END_ALLOW_THREADS if (err) return _mysql_Exception(self); - return PyInt_FromLong(err); + return PyLong_FromLong(err); } static char _mysql_ConnectionObject_sqlstate__doc__[] = @@ -820,7 +943,7 @@ _mysql_ConnectionObject_sqlstate( PyObject *noargs) { check_connection(self); - return PyString_FromString(mysql_sqlstate(&(self->connection))); + return PyUnicode_FromString(mysql_sqlstate(&(self->connection))); } static char _mysql_ConnectionObject_warning_count__doc__[] = @@ -835,7 +958,7 @@ _mysql_ConnectionObject_warning_count( PyObject *noargs) { check_connection(self); - return PyInt_FromLong(mysql_warning_count(&(self->connection))); + return PyLong_FromLong(mysql_warning_count(&(self->connection))); } static char _mysql_ConnectionObject_errno__doc__[] = @@ -850,7 +973,7 @@ _mysql_ConnectionObject_errno( PyObject *noargs) { check_connection(self); - return PyInt_FromLong((long)mysql_errno(&(self->connection))); + return PyLong_FromLong((long)mysql_errno(&(self->connection))); } static char _mysql_ConnectionObject_error__doc__[] = @@ -865,7 +988,7 @@ _mysql_ConnectionObject_error( PyObject *noargs) { check_connection(self); - return PyString_FromString(mysql_error(&(self->connection))); + return PyUnicode_FromString(mysql_error(&(self->connection))); } static char _mysql_escape_string__doc__[] = @@ -883,7 +1006,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); @@ -920,10 +1043,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; @@ -931,26 +1051,44 @@ _mysql_string_literal( if (PyBytes_Check(o)) { s = o; Py_INCREF(s); - } else { - s = PyObject_Str(o); - if (!s) return NULL; -#ifdef IS_PY3K - { - 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; } -#endif + 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, '\''); @@ -960,10 +1098,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 * @@ -1041,7 +1183,6 @@ _mysql_ResultObject_describe( if (!(d = PyTuple_New(n))) return NULL; for (i=0; iencoding == utf8) { name = PyUnicode_DecodeUTF8(fields[i].name, fields[i].name_length, "replace"); @@ -1054,10 +1195,6 @@ _mysql_ResultObject_describe( t = Py_BuildValue("(Niiiiii)", name, -#else - t = Py_BuildValue("(siiiiii)", - fields[i].name, -#endif (long) fields[i].type, (long) fields[i].max_length, (long) fields[i].length, @@ -1091,7 +1228,7 @@ _mysql_ResultObject_field_flags( if (!(d = PyTuple_New(n))) return NULL; for (i=0; itype) { - case FIELD_TYPE_TINY_BLOB: - case FIELD_TYPE_MEDIUM_BLOB: - case FIELD_TYPE_LONG_BLOB: - case FIELD_TYPE_BLOB: - case FIELD_TYPE_VAR_STRING: - case FIELD_TYPE_STRING: - case FIELD_TYPE_GEOMETRY: - case FIELD_TYPE_BIT: -#ifdef FIELD_TYPE_JSON - case FIELD_TYPE_JSON: -#else - case 245: // JSON -#endif - // Call converter with bytes - binary = 1; + case FIELD_TYPE_DECIMAL: + case FIELD_TYPE_NEWDECIMAL: + case FIELD_TYPE_TIMESTAMP: + case FIELD_TYPE_DATETIME: + case FIELD_TYPE_TIME: + case FIELD_TYPE_DATE: + binary = 0; // pass str, because these converters expect it break; - default: // e.g. FIELD_TYPE_DATETIME, etc. - // Call converter with unicode string - binary = 0; + default: // Default to just passing bytes + binary = 1; } return PyObject_CallFunction(converter, binary ? "y#" : "s#", rowitem, (Py_ssize_t)length); -#else - return PyObject_CallFunction(converter, - "s#", rowitem, (Py_ssize_t)length); -#endif } static PyObject * _mysql_row_to_tuple( _mysql_ResultObject *self, - MYSQL_ROW row) + MYSQL_ROW row, + PyObject *unused) { unsigned int n, i; unsigned long *length; @@ -1196,7 +1320,8 @@ _mysql_row_to_tuple( static PyObject * _mysql_row_to_dict( _mysql_ResultObject *self, - MYSQL_ROW row) + MYSQL_ROW row, + PyObject *cache) { unsigned int n, i; unsigned long *length; @@ -1212,30 +1337,48 @@ _mysql_row_to_dict( c = PyTuple_GET_ITEM(self->converter, i); v = _mysql_field_to_python(c, row[i], length[i], &fields[i], self->encoding); if (!v) goto error; - if (!PyMapping_HasKeyString(r, fields[i].name)) { - PyMapping_SetItemString(r, fields[i].name, v); + + PyObject *pyname = PyUnicode_FromString(fields[i].name); + if (pyname == NULL) { + Py_DECREF(v); + goto error; + } + int err = PyDict_Contains(r, pyname); + if (err < 0) { // error + Py_DECREF(v); + goto error; + } + if (err) { // duplicate + Py_DECREF(pyname); + pyname = PyUnicode_FromFormat("%s.%s", fields[i].table, fields[i].name); + if (pyname == NULL) { + Py_DECREF(v); + goto error; + } + } + + err = PyDict_SetItem(r, pyname, v); + if (cache) { + PyTuple_SET_ITEM(cache, i, pyname); } else { - int len; - char buf[256]; - strncpy(buf, fields[i].table, 256); - len = strlen(buf); - strncat(buf, ".", 256-len); - len = strlen(buf); - strncat(buf, fields[i].name, 256-len); - PyMapping_SetItemString(r, buf, v); + Py_DECREF(pyname); } Py_DECREF(v); + if (err) { + goto error; + } } return r; - error: - Py_XDECREF(r); +error: + Py_DECREF(r); return NULL; } static PyObject * _mysql_row_to_dict_old( _mysql_ResultObject *self, - MYSQL_ROW row) + MYSQL_ROW row, + PyObject *cache) { unsigned int n, i; unsigned long *length; @@ -1250,20 +1393,26 @@ _mysql_row_to_dict_old( PyObject *v; c = PyTuple_GET_ITEM(self->converter, i); v = _mysql_field_to_python(c, row[i], length[i], &fields[i], self->encoding); - if (!v) goto error; - { - int len=0; - char buf[256]=""; - if (strlen(fields[i].table)) { - strncpy(buf, fields[i].table, 256); - len = strlen(buf); - strncat(buf, ".", 256-len); - len = strlen(buf); - } - strncat(buf, fields[i].name, 256-len); - PyMapping_SetItemString(r, buf, v); + if (!v) { + goto error; } + + PyObject *pyname; + if (strlen(fields[i].table)) { + pyname = PyUnicode_FromFormat("%s.%s", fields[i].table, fields[i].name); + } else { + pyname = PyUnicode_FromString(fields[i].name); + } + int err = PyDict_SetItem(r, pyname, v); Py_DECREF(v); + if (cache) { + PyTuple_SET_ITEM(cache, i, pyname); + } else { + Py_DECREF(pyname); + } + if (err) { + goto error; + } } return r; error: @@ -1271,42 +1420,100 @@ _mysql_row_to_dict_old( return NULL; } -typedef PyObject *_PYFUNC(_mysql_ResultObject *, MYSQL_ROW); +static PyObject * +_mysql_row_to_dict_cached( + _mysql_ResultObject *self, + MYSQL_ROW row, + PyObject *cache) +{ + PyObject *r = PyDict_New(); + if (!r) { + return NULL; + } + + unsigned int n = mysql_num_fields(self->result); + unsigned long *length = mysql_fetch_lengths(self->result); + MYSQL_FIELD *fields = mysql_fetch_fields(self->result); + + for (unsigned int i=0; iconverter, i); + PyObject *v = _mysql_field_to_python(c, row[i], length[i], &fields[i], self->encoding); + if (!v) { + goto error; + } + + PyObject *pyname = PyTuple_GET_ITEM(cache, i); // borrowed + int err = PyDict_SetItem(r, pyname, v); + Py_DECREF(v); + if (err) { + goto error; + } + } + return r; + error: + Py_XDECREF(r); + return NULL; +} + + +typedef PyObject *_convertfunc(_mysql_ResultObject *, MYSQL_ROW, PyObject *); +static _convertfunc * const row_converters[] = { + _mysql_row_to_tuple, + _mysql_row_to_dict, + _mysql_row_to_dict_old +}; -int +Py_ssize_t _mysql__fetch_row( _mysql_ResultObject *self, - PyObject **r, - int skiprows, - int maxrows, - _PYFUNC *convert_row) + PyObject *r, /* list object */ + Py_ssize_t maxrows, + int how) { - int i; - MYSQL_ROW row; + _convertfunc *convert_row = row_converters[how]; - for (i = skiprows; i<(skiprows+maxrows); i++) { - PyObject *v; + PyObject *cache = NULL; + if (maxrows > 0 && how > 0) { + cache = PyTuple_New(mysql_num_fields(self->result)); + if (!cache) { + return -1; + } + } + + Py_ssize_t i; + for (i = 0; i < maxrows; i++) { + MYSQL_ROW 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); goto error; } if (!row) { - if (_PyTuple_Resize(r, i) == -1) goto error; break; } - v = convert_row(self, row); - if (!v) goto error; - PyTuple_SET_ITEM(*r, i, v); + PyObject *v = convert_row(self, row, cache); + if (!v) { + goto error; + } + if (cache) { + convert_row = _mysql_row_to_dict_cached; + } + if (PyList_Append(r, v)) { + Py_DECREF(v); + goto error; + } + Py_DECREF(v); } - return i-skiprows; - error: + Py_XDECREF(cache); + return i; +error: + Py_XDECREF(cache); return -1; } @@ -1325,60 +1532,64 @@ _mysql_ResultObject_fetch_row( PyObject *args, PyObject *kwargs) { - typedef PyObject *_PYFUNC(_mysql_ResultObject *, MYSQL_ROW); - static char *kwlist[] = { "maxrows", "how", NULL }; - static _PYFUNC *row_converters[] = - { - _mysql_row_to_tuple, - _mysql_row_to_dict, - _mysql_row_to_dict_old - }; - _PYFUNC *convert_row; - int maxrows=1, how=0, skiprows=0, rowsadded; + static char *kwlist[] = {"maxrows", "how", NULL }; + int maxrows=1, how=0; PyObject *r=NULL; if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|ii:fetch_row", kwlist, &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; } - convert_row = row_converters[how]; - if (maxrows) { - if (!(r = PyTuple_New(maxrows))) goto error; - rowsadded = _mysql__fetch_row(self, &r, skiprows, maxrows, - convert_row); - if (rowsadded == -1) goto error; - } else { + if (!maxrows) { if (self->use) { - maxrows = 1000; - if (!(r = PyTuple_New(maxrows))) goto error; - while (1) { - rowsadded = _mysql__fetch_row(self, &r, skiprows, - maxrows, convert_row); - if (rowsadded == -1) goto error; - skiprows += rowsadded; - if (rowsadded < maxrows) break; - if (_PyTuple_Resize(&r, skiprows+maxrows) == -1) - goto error; - } + maxrows = INT_MAX; } else { - /* XXX if overflow, maxrows<0? */ - maxrows = (int) mysql_num_rows(self->result); - if (!(r = PyTuple_New(maxrows))) goto error; - rowsadded = _mysql__fetch_row(self, &r, 0, - maxrows, convert_row); - if (rowsadded == -1) goto error; + // todo: preallocate. + maxrows = (Py_ssize_t) mysql_num_rows(self->result); } } - return r; + if (!(r = PyList_New(0))) goto error; + Py_ssize_t rowsadded = _mysql__fetch_row(self, r, maxrows, how); + if (rowsadded == -1) goto error; + + /* DB-API allows return rows as list. + * But we need to return list because Django expecting tuple. + */ + PyObject *t = PyList_AsTuple(r); + Py_DECREF(r); + return t; error: Py_XDECREF(r); 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\ @@ -1430,7 +1641,7 @@ _mysql_ConnectionObject_character_set_name( const char *s; check_connection(self); s = mysql_character_set_name(&(self->connection)); - return PyString_FromString(s); + return PyUnicode_FromString(s); } static char _mysql_ConnectionObject_set_character_set__doc__[] = @@ -1488,15 +1699,15 @@ _mysql_ConnectionObject_get_character_set_info( mysql_get_character_set_info(&(self->connection), &cs); if (!(result = PyDict_New())) return NULL; if (cs.csname) - PyDict_SetItemString(result, "name", PyString_FromString(cs.csname)); + PyDict_SetItemString(result, "name", PyUnicode_FromString(cs.csname)); if (cs.name) - PyDict_SetItemString(result, "collation", PyString_FromString(cs.name)); + PyDict_SetItemString(result, "collation", PyUnicode_FromString(cs.name)); if (cs.comment) - PyDict_SetItemString(result, "comment", PyString_FromString(cs.comment)); + PyDict_SetItemString(result, "comment", PyUnicode_FromString(cs.comment)); if (cs.dir) - PyDict_SetItemString(result, "dir", PyString_FromString(cs.dir)); - PyDict_SetItemString(result, "mbminlen", PyInt_FromLong(cs.mbminlen)); - PyDict_SetItemString(result, "mbmaxlen", PyInt_FromLong(cs.mbmaxlen)); + PyDict_SetItemString(result, "dir", PyUnicode_FromString(cs.dir)); + PyDict_SetItemString(result, "mbminlen", PyLong_FromLong(cs.mbminlen)); + PyDict_SetItemString(result, "mbmaxlen", PyLong_FromLong(cs.mbmaxlen)); return result; } #endif @@ -1531,7 +1742,7 @@ _mysql_get_client_info( PyObject *self, PyObject *noargs) { - return PyString_FromString(mysql_get_client_info()); + return PyUnicode_FromString(mysql_get_client_info()); } static char _mysql_ConnectionObject_get_host_info__doc__[] = @@ -1545,7 +1756,7 @@ _mysql_ConnectionObject_get_host_info( PyObject *noargs) { check_connection(self); - return PyString_FromString(mysql_get_host_info(&(self->connection))); + return PyUnicode_FromString(mysql_get_host_info(&(self->connection))); } static char _mysql_ConnectionObject_get_proto_info__doc__[] = @@ -1559,7 +1770,7 @@ _mysql_ConnectionObject_get_proto_info( PyObject *noargs) { check_connection(self); - return PyInt_FromLong((long)mysql_get_proto_info(&(self->connection))); + return PyLong_FromLong((long)mysql_get_proto_info(&(self->connection))); } static char _mysql_ConnectionObject_get_server_info__doc__[] = @@ -1573,7 +1784,7 @@ _mysql_ConnectionObject_get_server_info( PyObject *noargs) { check_connection(self); - return PyString_FromString(mysql_get_server_info(&(self->connection))); + return PyUnicode_FromString(mysql_get_server_info(&(self->connection))); } static char _mysql_ConnectionObject_info__doc__[] = @@ -1590,7 +1801,7 @@ _mysql_ConnectionObject_info( const char *s; check_connection(self); s = mysql_info(&(self->connection)); - if (s) return PyString_FromString(s); + if (s) return PyUnicode_FromString(s); Py_RETURN_NONE; } @@ -1622,15 +1833,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( @@ -1639,10 +1848,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; @@ -1660,7 +1871,7 @@ _mysql_ConnectionObject_field_count( PyObject *noargs) { check_connection(self); - return PyInt_FromLong((long)mysql_field_count(&(self->connection))); + return PyLong_FromLong((long)mysql_field_count(&(self->connection))); } static char _mysql_ConnectionObject_fileno__doc__[] = @@ -1674,7 +1885,7 @@ _mysql_ConnectionObject_fileno( PyObject *noargs) { check_connection(self); - return PyInt_FromLong(self->connection.net.fd); + return PyLong_FromLong(self->connection.net.fd); } static char _mysql_ResultObject_num_fields__doc__[] = @@ -1686,7 +1897,7 @@ _mysql_ResultObject_num_fields( PyObject *noargs) { check_result_connection(self); - return PyInt_FromLong((long)mysql_num_fields(self->result)); + return PyLong_FromLong((long)mysql_num_fields(self->result)); } static char _mysql_ResultObject_num_rows__doc__[] = @@ -1705,18 +1916,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\ @@ -1728,17 +1939,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; } @@ -1840,7 +2058,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 * @@ -1851,7 +2069,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; @@ -1875,7 +2093,7 @@ _mysql_ConnectionObject_stat( s = mysql_stat(&(self->connection)); Py_END_ALLOW_THREADS if (!s) return _mysql_Exception(self); - return PyString_FromString(s); + return PyUnicode_FromString(s); } static char _mysql_ConnectionObject_store_result__doc__[] = @@ -1933,10 +2151,8 @@ _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 PyInt_FromLong((long)pid); + return PyLong_FromLong((long)pid); } static char _mysql_ConnectionObject_use_result__doc__[] = @@ -1976,6 +2192,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) @@ -1983,7 +2236,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); @@ -1999,7 +2252,7 @@ _mysql_ConnectionObject_repr( self->connection.host, self); else snprintf(buf, 300, "<_mysql.connection closed at %p>", self); - return PyString_FromString(buf); + return PyUnicode_FromString(buf); } static char _mysql_ResultObject_data_seek__doc__[] = @@ -2032,7 +2285,7 @@ _mysql_ResultObject_repr( { char buf[300]; snprintf(buf, 300, "<_mysql.result object at %p>", self); - return PyString_FromString(buf); + return PyUnicode_FromString(buf); } static PyMethodDef _mysql_ConnectionObject_methods[] = { @@ -2271,6 +2524,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 */ }; @@ -2291,7 +2550,7 @@ static struct PyMemberDef _mysql_ConnectionObject_memberlist[] = { }, { "server_capabilities", - T_UINT, + T_ULONG, offsetof(_mysql_ConnectionObject,connection.server_capabilities), READONLY, "Capabilities of server; consult MySQLdb.constants.CLIENT" @@ -2305,7 +2564,7 @@ static struct PyMemberDef _mysql_ConnectionObject_memberlist[] = { }, { "client_flag", - T_UINT, + T_ULONG, offsetof(_mysql_ConnectionObject,connection.client_flag), READONLY, "Client flags; refer to MySQLdb.constants.CLIENT" @@ -2332,6 +2591,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, @@ -2377,13 +2642,9 @@ _mysql_ConnectionObject_getattro( PyObject *name) { const char *cname; -#ifdef IS_PY3K cname = PyUnicode_AsUTF8(name); -#else - cname = PyString_AsString(name); -#endif if (strcmp(cname, "closed") == 0) - return PyInt_FromLong((long)!(self->open)); + return PyLong_FromLong((long)!(self->open)); return PyObject_GenericGetAttr((PyObject *)self, name); } @@ -2624,7 +2885,6 @@ an argument are now methods of the result object. Deprecated functions\n\ (as of 3.23) are NOT implemented.\n\ "; -#ifdef IS_PY3K static struct PyModuleDef _mysqlmodule = { PyModuleDef_HEAD_INIT, "_mysql", /* name of module */ @@ -2636,14 +2896,14 @@ static struct PyModuleDef _mysqlmodule = { PyMODINIT_FUNC PyInit__mysql(void) -#else -DL_EXPORT(void) -init_mysql(void) -#endif { PyObject *dict, *module, *emod, *edict; -#ifdef IS_PY3K + if (mysql_library_init(0, NULL, NULL)) { + PyErr_SetString(PyExc_ImportError, "_mysql: mysql_library_init failed"); + return NULL; + } + if (PyType_Ready(&_mysql_ConnectionObject_Type) < 0) return NULL; if (PyType_Ready(&_mysql_ResultObject_Type) < 0) @@ -2651,15 +2911,6 @@ init_mysql(void) module = PyModule_Create(&_mysqlmodule); if (!module) return module; /* this really should never happen */ -#else - if (PyType_Ready(&_mysql_ConnectionObject_Type) < 0) - return; - if (PyType_Ready(&_mysql_ResultObject_Type) < 0) - return; - module = Py_InitModule4("_mysql", _mysql_methods, _mysql___doc__, - (PyObject *)NULL, PYTHON_API_VERSION); - if (!module) return; /* this really should never happen */ -#endif if (!(dict = PyModule_GetDict(module))) goto error; if (PyDict_SetItemString(dict, "version_info", @@ -2667,7 +2918,7 @@ init_mysql(void) dict, dict))) goto error; if (PyDict_SetItemString(dict, "__version__", - PyString_FromString(QUOTE(__version__)))) + PyUnicode_FromString(QUOTE(__version__)))) goto error; if (PyDict_SetItemString(dict, "connection", (PyObject *)&_mysql_ConnectionObject_Type)) @@ -2721,9 +2972,7 @@ init_mysql(void) PyErr_SetString(PyExc_ImportError, "_mysql: init failed"); module = NULL; } -#ifdef IS_PY3K return module; -#endif } /* vim: set ts=4 sts=4 sw=4 expandtab : */ diff --git a/MySQLdb/connections.py b/src/MySQLdb/connections.py similarity index 58% rename from MySQLdb/connections.py rename to src/MySQLdb/connections.py index cbfc9afb..a61aaaed 100644 --- a/MySQLdb/connections.py +++ b/src/MySQLdb/connections.py @@ -5,19 +5,33 @@ override Connection.default_cursor with a non-standard Cursor class. """ import re -import sys - -from MySQLdb import cursors, _mysql -from MySQLdb.compat import unicode, PY2 -from MySQLdb._exceptions import ( - Warning, Error, InterfaceError, DataError, - DatabaseError, OperationalError, IntegrityError, InternalError, - NotSupportedError, ProgrammingError, + +from . import cursors, _mysql +from ._exceptions import ( + Warning, + Error, + InterfaceError, + DataError, + DatabaseError, + OperationalError, + IntegrityError, + InternalError, + NotSupportedError, + ProgrammingError, ) +# Mapping from MySQL charset name to Python codec name +_charset_to_encoding = { + "utf8mb4": "utf8", + "utf8mb3": "utf8", + "latin1": "cp1252", + "koi8r": "koi8_r", + "koi8u": "koi8_u", +} re_numeric_part = re.compile(r"^(\d+)") + def numeric_part(s): """Returns the leading numeric part of a string. @@ -48,9 +62,9 @@ def __init__(self, *args, **kwargs): :param str host: host to connect :param str user: user to connect as :param str password: password to use - :param str passwd: alias of password, for backward compatibility + :param str passwd: alias of password (deprecated) :param str database: database to use - :param str db: alias of database, for backward compatibility + :param str db: alias of database (deprecated) :param int port: TCP/IP port to connect to :param str unix_socket: location of unix_socket to use :param dict conv: conversion dictionary, see MySQLdb.converters @@ -77,14 +91,24 @@ class object, used to create cursors (keyword only) columns are returned as bytes. Unicode objects will always be encoded to the connection's character set regardless of this setting. - Default to False on Python 2 and True on Python 3 - so that you can always get python `str` object by default. + Default to True. :param str charset: If supplied, the connection character set will be changed to this character set. - On Python 2, this option changes default value of `use_unicode` - option from False to True. + + :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: + `mysql_native_password` or `caching_sha2_password` :param str sql_mode: If supplied, the session SQL mode will be changed to this @@ -94,14 +118,37 @@ class object, used to create cursors (keyword only) :param int client_flag: flags to use or 0 (see MySQL docs or constants/CLIENTS.py) + :param bool multi_statements: + If True, enable multi statements for clients >= 4.1. + Defaults to True. + + :param str ssl_mode: + specify the security settings for connection to the server; + see the MySQL documentation for more details + (mysql_option(), MYSQL_OPT_SSL_MODE). + Only one of 'DISABLED', 'PREFERRED', 'REQUIRED', + 'VERIFY_CA', 'VERIFY_IDENTITY' can be specified. + :param dict ssl: dictionary or mapping contains SSL connection parameters; 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 str server_public_key_path: + specify the path to a file RSA public key file for caching_sha2_password. + See https://dev.mysql.com/doc/refman/9.0/en/caching-sha2-pluggable-authentication.html :param bool local_infile: - enables LOAD LOCAL INFILE; zero disables + sets ``MYSQL_OPT_LOCAL_INFILE`` in ``mysql_options()`` enabling LOAD LOCAL INFILE from any path; zero disables; + + :param str local_infile_dir: + sets ``MYSQL_OPT_LOAD_DATA_LOCAL_DIR`` in ``mysql_options()`` enabling LOAD LOCAL INFILE from any path; + if ``local_infile`` is set to ``True`` then this is ignored; + + supported for mysql version >= 8.0.21 :param bool autocommit: If False (default), autocommit is disabled. @@ -117,17 +164,16 @@ 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() - if 'database' in kwargs2: - kwargs2['db'] = kwargs2.pop('database') - if 'password' in kwargs2: - kwargs2['passwd'] = kwargs2.pop('password') + if "db" in kwargs2: + kwargs2["database"] = kwargs2.pop("db") + if "passwd" in kwargs2: + kwargs2["password"] = kwargs2.pop("passwd") - if 'conv' in kwargs: - conv = kwargs['conv'] + if "conv" in kwargs: + conv = kwargs["conv"] else: conv = conversions @@ -137,74 +183,88 @@ class object, used to create cursors (keyword only) conv2[k] = v[:] else: conv2[k] = v - kwargs2['conv'] = conv2 - - cursorclass = kwargs2.pop('cursorclass', self.default_cursor) - charset = kwargs2.pop('charset', '') - - if charset or not PY2: - use_unicode = True - else: - use_unicode = False - - use_unicode = kwargs2.pop('use_unicode', use_unicode) - sql_mode = kwargs2.pop('sql_mode', '') - self._binary_prefix = kwargs2.pop('binary_prefix', False) - - client_flag = kwargs.get('client_flag', 0) - client_version = tuple([ numeric_part(n) for n in _mysql.get_client_info().split('.')[:2] ]) - if client_version >= (4, 1): + kwargs2["conv"] = conv2 + + 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) + + client_flag = kwargs.get("client_flag", 0) + client_flag |= CLIENT.MULTI_RESULTS + multi_statements = kwargs2.pop("multi_statements", True) + if multi_statements: client_flag |= CLIENT.MULTI_STATEMENTS - if client_version >= (5, 0): - client_flag |= CLIENT.MULTI_RESULTS - - kwargs2['client_flag'] = client_flag + kwargs2["client_flag"] = client_flag # PEP-249 requires autocommit to be initially off - autocommit = kwargs2.pop('autocommit', False) - - super(Connection, self).__init__(*args, **kwargs2) - self.cursorclass = cursorclass - self.encoders = dict([ (k, v) for k, v in conv.items() - if type(k) is not int ]) - - # XXX THIS IS GARBAGE: While this is just a garbage and undocumented, - # Django 1.11 depends on it. And they don't fix it because - # they are in security-only fix mode. - # So keep this garbage for now. This will be removed in 1.5. - # See PyMySQL/mysqlclient-python#306 - self.encoders[bytes] = bytes + autocommit = kwargs2.pop("autocommit", False) - self._server_version = tuple([ numeric_part(n) for n in self.get_server_info().split('.')[:2] ]) + self._set_attributes(*args, **kwargs2) + super().__init__(*args, **kwargs2) - self.encoding = 'ascii' # overridden in set_character_set() - db = proxy(self) - - def unicode_literal(u, dummy=None): - return db.string_literal(u.encode(db.encoding)) + self.cursorclass = cursorclass + 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]] + ) + self.encoding = "ascii" # overridden in set_character_set() 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) if use_unicode: - for t in (FIELD_TYPE.STRING, FIELD_TYPE.VAR_STRING, FIELD_TYPE.VARCHAR, FIELD_TYPE.TINY_BLOB, - FIELD_TYPE.MEDIUM_BLOB, FIELD_TYPE.LONG_BLOB, FIELD_TYPE.BLOB): + for t in ( + FIELD_TYPE.STRING, + FIELD_TYPE.VAR_STRING, + FIELD_TYPE.VARCHAR, + FIELD_TYPE.TINY_BLOB, + FIELD_TYPE.MEDIUM_BLOB, + FIELD_TYPE.LONG_BLOB, + FIELD_TYPE.BLOB, + ): 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] = unicode + self.converter[FIELD_TYPE.JSON] = str - self.encoders[unicode] = unicode_literal self._transactional = self.server_capabilities & CLIENT.TRANSACTIONS if self._transactional: if autocommit is not None: self.autocommit(autocommit) self.messages = [] + def _set_attributes(self, host=None, user=None, password=None, database="", port=3306, + unix_socket=None, **kwargs): + """set some attributes for otel""" + if unix_socket and not host: + host = "localhost" + # support opentelemetry-instrumentation-dbapi + self.host = host + # _mysql.Connection provides self.port + self.user = user + self.database = database + # otel-inst-mysqlclient uses db instead of database. + self.db = database + # NOTE: We have not supported semantic conventions yet. + # https://opentelemetry.io/docs/specs/semconv/database/sql/ + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + def autocommit(self, on): on = bool(on) if self.get_autocommit() != on: @@ -229,11 +289,11 @@ def _bytes_literal(self, bs): assert isinstance(bs, (bytes, bytearray)) x = self.string_literal(bs) # x is escaped and quoted bytes if self._binary_prefix: - return b'_binary' + x + return b"_binary" + x return x def _tuple_literal(self, t): - return b"(%s)" % (b','.join(map(self.literal, t))) + return b"(%s)" % (b",".join(map(self.literal, t))) def literal(self, o): """If o is a single object, returns an SQL literal as a string. @@ -243,20 +303,17 @@ def literal(self, o): Non-standard. For internal use; do not use this in your applications. """ - if isinstance(o, unicode): + if isinstance(o, str): s = self.string_literal(o.encode(self.encoding)) elif isinstance(o, bytearray): s = self._bytes_literal(o) elif isinstance(o, bytes): - if PY2: - s = self.string_literal(o) - else: - s = self._bytes_literal(o) + s = self._bytes_literal(o) elif isinstance(o, (tuple, list)): s = self._tuple_literal(o) else: s = self.escape(o, self.encoders) - if isinstance(s, unicode): + if isinstance(s, str): s = s.encode(self.encoding) assert isinstance(s, bytes) return s @@ -268,35 +325,13 @@ def begin(self): """ self.query(b"BEGIN") - if not hasattr(_mysql.connection, 'warning_count'): - - def warning_count(self): - """Return the number of warnings generated from the - last query. This is derived from the info() method.""" - info = self.info() - if info: - return int(info.split()[-1]) - else: - return 0 - - def set_character_set(self, charset): - """Set the connection character set to charset. The character - set can only be changed in MySQL-4.1 and newer. If you try - to change the character set from the current value in an - older version, NotSupportedError will be raised.""" - if charset in ("utf8mb4", "utf8mb3"): - py_charset = "utf8" - else: - py_charset = charset - if self.character_set_name() != charset: - try: - super(Connection, self).set_character_set(charset) - except AttributeError: - if self._server_version < (4, 1): - raise NotSupportedError("server is too old to set charset") - self.query('SET NAMES %s' % charset) - self.store_result() - self.encoding = py_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 @@ -311,7 +346,8 @@ def show_warnings(self): sequence of tuples of (Level, Code, Message). This is only supported in MySQL-4.1 and up. If your server is an earlier version, an empty sequence is returned.""" - if self._server_version < (4,1): return () + if self._server_version < (4, 1): + return () self.query("SHOW WARNINGS") r = self.store_result() warnings = r.fetch_row(0) @@ -328,4 +364,5 @@ def show_warnings(self): ProgrammingError = ProgrammingError NotSupportedError = NotSupportedError + # vim: colorcolumn=100 diff --git a/MySQLdb/constants/CLIENT.py b/src/MySQLdb/constants/CLIENT.py similarity index 90% rename from MySQLdb/constants/CLIENT.py rename to src/MySQLdb/constants/CLIENT.py index 6559917b..35f578cc 100644 --- a/MySQLdb/constants/CLIENT.py +++ b/src/MySQLdb/constants/CLIENT.py @@ -20,10 +20,8 @@ INTERACTIVE = 1024 SSL = 2048 IGNORE_SIGPIPE = 4096 -TRANSACTIONS = 8192 # mysql_com.h was WRONG prior to 3.23.35 +TRANSACTIONS = 8192 # mysql_com.h was WRONG prior to 3.23.35 RESERVED = 16384 SECURE_CONNECTION = 32768 MULTI_STATEMENTS = 65536 MULTI_RESULTS = 131072 - - diff --git a/MySQLdb/constants/CR.py b/src/MySQLdb/constants/CR.py similarity index 90% rename from MySQLdb/constants/CR.py rename to src/MySQLdb/constants/CR.py index 753408ee..9467ae11 100644 --- a/MySQLdb/constants/CR.py +++ b/src/MySQLdb/constants/CR.py @@ -9,16 +9,18 @@ """ Usage: python CR.py [/path/to/mysql/errmsg.h ...] >> CR.py """ - import fileinput, re + import fileinput + import re + data = {} error_last = None for line in fileinput.input(): - line = re.sub(r'/\*.*?\*/', '', line) - m = re.match(r'^\s*#define\s+CR_([A-Z0-9_]+)\s+(\d+)(\s.*|$)', line) + line = re.sub(r"/\*.*?\*/", "", line) + m = re.match(r"^\s*#define\s+CR_([A-Z0-9_]+)\s+(\d+)(\s.*|$)", line) if m: name = m.group(1) value = int(m.group(2)) - if name == 'ERROR_LAST': + if name == "ERROR_LAST": if error_last is None or error_last < value: error_last = value continue @@ -27,9 +29,9 @@ data[value].add(name) for value, names in sorted(data.items()): for name in sorted(names): - print('%s = %s' % (name, value)) + print(f"{name} = {value}") if error_last is not None: - print('ERROR_LAST = %s' % error_last) + print("ERROR_LAST = %s" % error_last) ERROR_FIRST = 2000 diff --git a/MySQLdb/constants/ER.py b/src/MySQLdb/constants/ER.py similarity index 98% rename from MySQLdb/constants/ER.py rename to src/MySQLdb/constants/ER.py index 2e623b51..8c5ece24 100644 --- a/MySQLdb/constants/ER.py +++ b/src/MySQLdb/constants/ER.py @@ -8,18 +8,20 @@ """ Usage: python ER.py [/path/to/mysql/mysqld_error.h ...] >> ER.py """ - import fileinput, re + import fileinput + import re + data = {} error_last = None for line in fileinput.input(): - line = re.sub(r'/\*.*?\*/', '', line) - m = re.match(r'^\s*#define\s+((ER|WARN)_[A-Z0-9_]+)\s+(\d+)\s*', line) + line = re.sub(r"/\*.*?\*/", "", line) + m = re.match(r"^\s*#define\s+((ER|WARN)_[A-Z0-9_]+)\s+(\d+)\s*", line) if m: name = m.group(1) - if name.startswith('ER_'): + if name.startswith("ER_"): name = name[3:] value = int(m.group(3)) - if name == 'ERROR_LAST': + if name == "ERROR_LAST": if error_last is None or error_last < value: error_last = value continue @@ -28,9 +30,9 @@ data[value].add(name) for value, names in sorted(data.items()): for name in sorted(names): - print('%s = %s' % (name, value)) + print(f"{name} = {value}") if error_last is not None: - print('ERROR_LAST = %s' % error_last) + print("ERROR_LAST = %s" % error_last) ERROR_FIRST = 1000 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/src/MySQLdb/constants/__init__.py b/src/MySQLdb/constants/__init__.py new file mode 100644 index 00000000..0372265b --- /dev/null +++ b/src/MySQLdb/constants/__init__.py @@ -0,0 +1 @@ +__all__ = ["CR", "FIELD_TYPE", "CLIENT", "ER", "FLAG"] diff --git a/MySQLdb/converters.py b/src/MySQLdb/converters.py similarity index 86% rename from MySQLdb/converters.py rename to src/MySQLdb/converters.py index 645e3780..d6fdc01c 100644 --- a/MySQLdb/converters.py +++ b/src/MySQLdb/converters.py @@ -32,16 +32,24 @@ """ from decimal import Decimal -from MySQLdb._mysql import string_literal, escape +from MySQLdb._mysql import string_literal from MySQLdb.constants import FIELD_TYPE, FLAG -from MySQLdb.times import * -from MySQLdb.compat import PY2, long, unicode +from MySQLdb.times import ( + Date, + DateTimeType, + DateTime2literal, + DateTimeDeltaType, + DateTimeDelta2literal, + DateTime_or_None, + TimeDelta_or_None, + Date_or_None, +) from MySQLdb._exceptions import ProgrammingError -NoneType = type(None) - import array +NoneType = type(None) + try: ArrayType = array.ArrayType except AttributeError: @@ -49,28 +57,33 @@ def Bool2Str(s, d): - return b'1' if s else b'0' + return b"1" if s else b"0" + def Set2Str(s, d): # Only support ascii string. Not tested. - return string_literal(','.join(s)) + return string_literal(",".join(s)) + def Thing2Str(s, d): """Convert something into a string via str().""" return str(s) + 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' + if "e" not in s: + s += "e0" return s + def None2NULL(o, d): """Convert None to NULL.""" return b"NULL" + def Thing2Literal(o, d): """Convert something into a SQL string literal. If using MySQL-3.23 or newer, string_literal() is a method of the @@ -78,18 +91,20 @@ def Thing2Literal(o, d): that method when the connection is created.""" return string_literal(o) + def Decimal2Literal(o, d): - return format(o, 'f') + return format(o, "f") + def array2Str(o, d): return Thing2Literal(o.tostring(), d) + # bytes or str regarding to BINARY_FLAG. -_bytes_or_str = ((FLAG.BINARY, bytes), (None, unicode)) +_bytes_or_str = ((FLAG.BINARY, bytes), (None, str)) conversions = { int: Thing2Str, - long: Thing2Str, float: Float2Str, NoneType: None2NULL, ArrayType: array2Str, @@ -99,7 +114,6 @@ def array2Str(o, d): DateTimeDeltaType: DateTimeDelta2literal, set: Set2Str, Decimal: Decimal2Literal, - FIELD_TYPE.TINY: int, FIELD_TYPE.SHORT: int, FIELD_TYPE.LONG: int, @@ -114,7 +128,6 @@ def array2Str(o, d): FIELD_TYPE.DATETIME: DateTime_or_None, FIELD_TYPE.TIME: TimeDelta_or_None, FIELD_TYPE.DATE: Date_or_None, - FIELD_TYPE.TINY_BLOB: bytes, FIELD_TYPE.MEDIUM_BLOB: bytes, FIELD_TYPE.LONG_BLOB: bytes, diff --git a/MySQLdb/cursors.py b/src/MySQLdb/cursors.py similarity index 71% rename from MySQLdb/cursors.py rename to src/MySQLdb/cursors.py index 82eb64e7..70fbeea4 100644 --- a/MySQLdb/cursors.py +++ b/src/MySQLdb/cursors.py @@ -3,29 +3,27 @@ This module implements Cursors of various types for MySQLdb. By default, MySQLdb uses the Cursor class. """ -from __future__ import print_function, absolute_import -from functools import partial import re -import sys -from .compat import unicode -from ._exceptions import ( - Warning, Error, InterfaceError, DataError, - DatabaseError, OperationalError, IntegrityError, InternalError, - NotSupportedError, ProgrammingError) +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( - r"\s*((?:INSERT|REPLACE)\b.+\bVALUES?\s*)" + - r"(\(\s*(?:%s|%\(.+\)s)\s*(?:,\s*(?:%s|%\(.+\)s)\s*)*\))" + - r"(\s*(?:ON DUPLICATE.*)?);?\s*\Z", - re.IGNORECASE | re.DOTALL) - - -class BaseCursor(object): + "".join( + [ + r"\s*((?:INSERT|REPLACE)\b.+\bVALUES?\s*)", + r"(\(\s*(?:%s|%\(.+\)s)\s*(?:,\s*(?:%s|%\(.+\)s)\s*)*\))", + r"(\s*(?:ON DUPLICATE.*)?);?\s*\Z", + ] + ), + re.IGNORECASE | re.DOTALL, +) + + +class BaseCursor: """A base for Cursor classes. Useful attributes: description @@ -42,16 +40,24 @@ class BaseCursor(object): default number of rows fetchmany() will fetch """ - #: Max stetement size which :meth:`executemany` generates. + #: Max statement size which :meth:`executemany` generates. #: #: Max size of allowed statement is max_allowed_packet - packet_header_size. #: Default value of max_allowed_packet is 1048576. - max_stmt_length = 64*1024 + max_stmt_length = 64 * 1024 from ._exceptions import ( - MySQLError, Warning, Error, InterfaceError, - DatabaseError, DataError, OperationalError, IntegrityError, - InternalError, ProgrammingError, NotSupportedError, + MySQLError, + Warning, + Error, + InterfaceError, + DatabaseError, + DataError, + OperationalError, + IntegrityError, + InternalError, + ProgrammingError, + NotSupportedError, ) connection = None @@ -60,31 +66,41 @@ 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 - # XXX THIS IS GARBAGE: While this is totally garbage and private, - # Django 1.11 depends on it. And they don't fix it because - # they are in security-only fix mode. - # So keep this garbage for now. This will be removed in 1.5. - # See PyMySQL/mysqlclient-python#303 - self._last_executed = None - self.lastrowid = None - self.messages = [] self._result = None - self._warnings = None 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 @@ -96,29 +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, unicode): - 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)): - return tuple(literal(ensure_bytes(arg)) for arg in args) - elif isinstance(args, dict): - return {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 - return literal(ensure_bytes(args)) - def _check_executed(self): if not self._executed: raise ProgrammingError("execute() first") @@ -130,7 +123,6 @@ def nextset(self): """ if self._executed: self.fetchall() - del self.messages[:] db = self._get_db() nr = db.next_result() @@ -151,7 +143,6 @@ def _do_get_result(self, db): self.rowcount = db.affected_rows() self.rownumber = 0 self.lastrowid = db.insert_id() - self._warnings = None def _post_get_result(self): pass @@ -180,18 +171,26 @@ 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, unicode): + if isinstance(query, str): query = query.encode(db.encoding) if args is not None: if isinstance(args, dict): nargs = {} for key, item in args.items(): - if isinstance(key, unicode): + if isinstance(key, str): key = key.encode(db.encoding) nargs[key] = db.literal(item) args = nargs @@ -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 @@ -218,8 +229,6 @@ def executemany(self, query, args): REPLACE. Otherwise it is equivalent to looping over args with execute(). """ - del self.messages[:] - if not args: return @@ -227,36 +236,41 @@ def executemany(self, query, args): if m: q_prefix = m.group(1) % () q_values = m.group(2).rstrip() - q_postfix = m.group(3) or '' - assert q_values[0] == '(' and q_values[-1] == ')' - return self._do_execute_many(q_prefix, q_values, q_postfix, args, - self.max_stmt_length, - self._get_db().encoding) + q_postfix = m.group(3) or "" + assert q_values[0] == "(" and q_values[-1] == ")" + return self._do_execute_many( + q_prefix, + q_values, + q_postfix, + args, + self.max_stmt_length, + self._get_db().encoding, + ) self.rowcount = sum(self.execute(query, arg) for arg in args) return self.rowcount - def _do_execute_many(self, prefix, values, postfix, args, max_stmt_length, encoding): - conn = self._get_db() - escape = self._escape_args - if isinstance(prefix, unicode): + def _do_execute_many( + self, prefix, values, postfix, args, max_stmt_length, encoding + ): + if isinstance(prefix, str): prefix = prefix.encode(encoding) - if isinstance(values, unicode): + if isinstance(values, str): values = values.encode(encoding) - if isinstance(postfix, unicode): + if isinstance(postfix, str): 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) else: - sql += b',' + sql += b"," sql += v rows += self.execute(sql + postfix) self.rowcount = rows @@ -291,29 +305,32 @@ def callproc(self, procname, args=()): disconnected. """ db = self._get_db() - if isinstance(procname, unicode): + if isinstance(procname, str): procname = procname.encode(db.encoding) if args: - fmt = b'@_' + procname + b'_%d=%s' - q = b'SET %s' % b','.join(fmt % (index, db.literal(arg)) - for index, arg in enumerate(args)) + fmt = b"@_" + procname + b"_%d=%s" + q = b"SET %s" % b",".join( + fmt % (index, db.literal(arg)) for index, arg in enumerate(args) + ) self._query(q) self.nextset() - q = b"CALL %s(%s)" % (procname, - b','.join([b'@_%s_%d' % (procname, i) - for i in range(len(args))])) + q = b"CALL %s(%s)" % ( + procname, + b",".join([b"@_%s_%d" % (procname, i) for i in range(len(args))]), + ) self._query(q) return 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() self._executed = q - self._last_executed = q # XXX THIS IS GARBAGE: See above. return self.rowcount def _fetch_row(self, size=1): @@ -336,7 +353,7 @@ def __iter__(self): NotSupportedError = NotSupportedError -class CursorStoreResultMixIn(object): +class CursorStoreResultMixIn: """This is a MixIn class which causes the entire result set to be stored on the client side, i.e. it uses mysql_store_result(). If the result set can be very large, consider adding a LIMIT clause to your @@ -364,21 +381,21 @@ def fetchmany(self, size=None): than size. If size is not defined, cursor.arraysize is used.""" self._check_executed() end = self.rownumber + (size or self.arraysize) - result = self._rows[self.rownumber:end] + result = self._rows[self.rownumber : end] self.rownumber = min(end, len(self._rows)) 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:] + result = self._rows[self.rownumber :] else: result = self._rows self.rownumber = len(self._rows) return result - def scroll(self, value, mode='relative'): + def scroll(self, value, mode="relative"): """Scroll the cursor in the result set to a new position according to mode. @@ -386,9 +403,9 @@ def scroll(self, value, mode='relative'): the current position in the result set, if set to 'absolute', value states an absolute target position.""" self._check_executed() - if mode == 'relative': + if mode == "relative": r = self.rownumber + value - elif mode == 'absolute': + elif mode == "absolute": r = value else: raise ProgrammingError("unknown scroll mode %s" % repr(mode)) @@ -398,11 +415,11 @@ def scroll(self, value, mode='relative'): def __iter__(self): self._check_executed() - result = self.rownumber and self._rows[self.rownumber:] or self._rows + result = self.rownumber and self._rows[self.rownumber :] or self._rows return iter(result) -class CursorUseResultMixIn(object): +class CursorUseResultMixIn: """This is a MixIn class which causes the result set to be stored in the server and sent row-by-row to client side, i.e. it uses @@ -431,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) @@ -449,39 +466,35 @@ def next(self): __next__ = next -class CursorTupleRowsMixIn(object): +class CursorTupleRowsMixIn: """This is a MixIn class that causes all rows to be returned as tuples, which is the standard form required by DB API.""" _fetch_type = 0 -class CursorDictRowsMixIn(object): +class CursorDictRowsMixIn: """This is a MixIn class that causes all rows to be returned as dictionaries. This is a non-standard feature.""" _fetch_type = 1 -class Cursor(CursorStoreResultMixIn, CursorTupleRowsMixIn, - BaseCursor): +class Cursor(CursorStoreResultMixIn, CursorTupleRowsMixIn, BaseCursor): """This is the standard Cursor class that returns rows as tuples and stores the result set in the client.""" -class DictCursor(CursorStoreResultMixIn, CursorDictRowsMixIn, - BaseCursor): +class DictCursor(CursorStoreResultMixIn, CursorDictRowsMixIn, BaseCursor): """This is a Cursor class that returns rows as dictionaries and stores the result set in the client.""" -class SSCursor(CursorUseResultMixIn, CursorTupleRowsMixIn, - BaseCursor): +class SSCursor(CursorUseResultMixIn, CursorTupleRowsMixIn, BaseCursor): """This is a Cursor class that returns rows as tuples and stores the result set in the server.""" -class SSDictCursor(CursorUseResultMixIn, CursorDictRowsMixIn, - BaseCursor): +class SSDictCursor(CursorUseResultMixIn, CursorDictRowsMixIn, BaseCursor): """This is a Cursor class that returns rows as dictionaries and stores the result set in the server.""" diff --git a/src/MySQLdb/release.py b/src/MySQLdb/release.py new file mode 100644 index 00000000..234d9958 --- /dev/null +++ b/src/MySQLdb/release.py @@ -0,0 +1,3 @@ +__author__ = "Inada Naoki " +__version__ = "2.2.7" +version_info = (2, 2, 7, "final", 0) diff --git a/MySQLdb/times.py b/src/MySQLdb/times.py similarity index 71% rename from MySQLdb/times.py rename to src/MySQLdb/times.py index d47c8fb8..915d827b 100644 --- a/MySQLdb/times.py +++ b/src/MySQLdb/times.py @@ -16,34 +16,50 @@ DateTimeDeltaType = timedelta DateTimeType = datetime + def DateFromTicks(ticks): """Convert UNIX ticks into a date instance.""" return date(*localtime(ticks)[:3]) + def TimeFromTicks(ticks): """Convert UNIX ticks into a time instance.""" return time(*localtime(ticks)[3:6]) + def TimestampFromTicks(ticks): """Convert UNIX ticks into a datetime instance.""" return datetime(*localtime(ticks)[:6]) + format_TIME = format_DATE = str + def format_TIMEDELTA(v): seconds = int(v.seconds) % 60 minutes = int(v.seconds // 60) % 60 hours = int(v.seconds // 3600) % 24 - return '%d %d:%d:%d' % (v.days, hours, minutes, seconds) + return "%d %d:%d:%d" % (v.days, hours, minutes, seconds) + def format_TIMESTAMP(d): """ :type d: datetime.datetime """ if d.microsecond: - fmt = "{0.year:04}-{0.month:02}-{0.day:02} {0.hour:02}:{0.minute:02}:{0.second:02}.{0.microsecond:06}" + fmt = " ".join( + [ + "{0.year:04}-{0.month:02}-{0.day:02}", + "{0.hour:02}:{0.minute:02}:{0.second:02}.{0.microsecond:06}", + ] + ) else: - fmt = "{0.year:04}-{0.month:02}-{0.day:02} {0.hour:02}:{0.minute:02}:{0.second:02}" + fmt = " ".join( + [ + "{0.year:04}-{0.month:02}-{0.day:02}", + "{0.hour:02}:{0.minute:02}:{0.second:02}", + ] + ) return fmt.format(d) @@ -64,32 +80,32 @@ def DateTime_or_None(s): return None return datetime( - int(s[:4]), # year - int(s[5:7]), # month - int(s[8:10]), # day + int(s[:4]), # year + int(s[5:7]), # month + int(s[8:10]), # day int(s[11:13] or 0), # hour int(s[14:16] or 0), # minute int(s[17:19] or 0), # second - micros, # microsecond + micros, # microsecond ) except ValueError: return None + def TimeDelta_or_None(s): try: - h, m, s = s.split(':') - if '.' in s: - s, ms = s.split('.') - ms = ms.ljust(6, '0') + h, m, s = s.split(":") + if "." in s: + s, ms = s.split(".") + ms = ms.ljust(6, "0") else: ms = 0 - if h[0] == '-': + if h[0] == "-": negative = True else: negative = False h, m, s, ms = abs(int(h)), int(m), int(s), int(ms) - td = timedelta(hours=h, minutes=m, seconds=s, - microseconds=ms) + td = timedelta(hours=h, minutes=m, seconds=s, microseconds=ms) if negative: return -td else: @@ -98,34 +114,37 @@ def TimeDelta_or_None(s): # unpacking or int/float conversion failed return None + def Time_or_None(s): try: - h, m, s = s.split(':') - if '.' in s: - s, ms = s.split('.') - ms = ms.ljust(6, '0') + h, m, s = s.split(":") + if "." in s: + s, ms = s.split(".") + ms = ms.ljust(6, "0") else: ms = 0 h, m, s, ms = int(h), int(m), int(s), int(ms) - return time(hour=h, minute=m, second=s, - microsecond=ms) + return time(hour=h, minute=m, second=s, microsecond=ms) except ValueError: return None + def Date_or_None(s): try: return date( - int(s[:4]), # year - int(s[5:7]), # month - int(s[8:10]), # day - ) + int(s[:4]), + int(s[5:7]), + int(s[8:10]), + ) # year # month # day except ValueError: return None + def DateTime2literal(d, c): """Format a DateTime object as an ISO timestamp.""" return string_literal(format_TIMESTAMP(d)) + def DateTimeDelta2literal(d, c): """Format a DateTimeDelta object as a time.""" return string_literal(format_TIMEDELTA(d)) diff --git a/tests/actions.cnf b/tests/actions.cnf new file mode 100644 index 00000000..d20296d6 --- /dev/null +++ b/tests/actions.cnf @@ -0,0 +1,11 @@ +# To create your own custom version of this file, read +# http://dev.mysql.com/doc/refman/5.1/en/option-files.html +# and set TESTDB in your environment to the name of the file + +[MySQLdb-tests] +host = 127.0.0.1 +port = 3306 +user = root +database = mysqldb_test +password = root +default-character-set = utf8mb4 diff --git a/tests/capabilities.py b/tests/capabilities.py index 5d913790..1e695e9e 100644 --- a/tests/capabilities.py +++ b/tests/capabilities.py @@ -6,47 +6,49 @@ """ from time import time -import array import unittest from configdb import connection_factory -from MySQLdb.compat import unichr - class DatabaseTest(unittest.TestCase): - db_module = None connect_args = () connect_kwargs = dict() - create_table_extra = '' + create_table_extra = "" rows = 10 debug = False def setUp(self): - import gc db = connection_factory(**self.connect_kwargs) self.connection = db self.cursor = db.cursor() - self.BLOBUText = u''.join([unichr(i) for i in range(16384)]) - self.BLOBBinary = self.db_module.Binary((u''.join([unichr(i) for i in range(256)] * 16)).encode('latin1')) + self.BLOBUText = "".join([chr(i) for i in range(16384)]) + self.BLOBBinary = self.db_module.Binary( + ("".join([chr(i) for i in range(256)] * 16)).encode("latin1") + ) leak_test = True def tearDown(self): if self.leak_test: import gc + del self.cursor orphans = gc.collect() - self.failIf(orphans, "%d orphaned objects found after deleting cursor" % orphans) + self.assertFalse( + orphans, "%d orphaned objects found after deleting cursor" % orphans + ) del self.connection orphans = gc.collect() - self.failIf(orphans, "%d orphaned objects found after deleting connection" % orphans) + self.assertFalse( + orphans, "%d orphaned objects found after deleting connection" % orphans + ) def table_exists(self, name): try: - self.cursor.execute('select * from %s where 1=0' % name) - except: + self.cursor.execute("select * from %s where 1=0" % name) + except Exception: return False else: return True @@ -57,98 +59,110 @@ def quote_identifier(self, ident): def new_table_name(self): i = id(self.cursor) while True: - name = self.quote_identifier('tb%08x' % i) + name = self.quote_identifier("tb%08x" % i) if not self.table_exists(name): return name i = i + 1 def create_table(self, columndefs): + """Create a table using a list of column definitions given in + columndefs. - """ Create a table using a list of column definitions given in - columndefs. - - generator must be a function taking arguments (row_number, - col_number) returning a suitable data object for insertion - into the table. + generator must be a function taking arguments (row_number, + col_number) returning a suitable data object for insertion + into the table. """ self.table = self.new_table_name() - self.cursor.execute('CREATE TABLE %s (%s) %s' % - (self.table, - ',\n'.join(columndefs), - self.create_table_extra)) + self.cursor.execute( + "CREATE TABLE %s (%s) %s" + % (self.table, ",\n".join(columndefs), self.create_table_extra) + ) def check_data_integrity(self, columndefs, generator): # insert self.create_table(columndefs) - insert_statement = ('INSERT INTO %s VALUES (%s)' % - (self.table, - ','.join(['%s'] * len(columndefs)))) - data = [ [ generator(i,j) for j in range(len(columndefs)) ] - for i in range(self.rows) ] + insert_statement = "INSERT INTO {} VALUES ({})".format( + self.table, + ",".join(["%s"] * len(columndefs)), + ) + data = [ + [generator(i, j) for j in range(len(columndefs))] for i in range(self.rows) + ] self.cursor.executemany(insert_statement, data) self.connection.commit() # verify - self.cursor.execute('select * from %s' % self.table) - l = self.cursor.fetchall() - self.assertEqual(len(l), self.rows) + self.cursor.execute("select * from %s" % self.table) + res = self.cursor.fetchall() + self.assertEqual(len(res), self.rows) try: for i in range(self.rows): for j in range(len(columndefs)): - self.assertEqual(l[i][j], generator(i,j)) + self.assertEqual(res[i][j], generator(i, j)) finally: if not self.debug: - self.cursor.execute('drop table %s' % (self.table)) + self.cursor.execute("drop table %s" % (self.table)) def test_transactions(self): - columndefs = ( 'col1 INT', 'col2 VARCHAR(255)') + columndefs = ("col1 INT", "col2 VARCHAR(255)") + def generator(row, col): - if col == 0: return row - else: return ('%i' % (row%10))*255 + if col == 0: + return row + else: + return ("%i" % (row % 10)) * 255 + self.create_table(columndefs) - insert_statement = ('INSERT INTO %s VALUES (%s)' % - (self.table, - ','.join(['%s'] * len(columndefs)))) - data = [ [ generator(i,j) for j in range(len(columndefs)) ] - for i in range(self.rows) ] + insert_statement = "INSERT INTO {} VALUES ({})".format( + self.table, + ",".join(["%s"] * len(columndefs)), + ) + data = [ + [generator(i, j) for j in range(len(columndefs))] for i in range(self.rows) + ] self.cursor.executemany(insert_statement, data) # verify self.connection.commit() - self.cursor.execute('select * from %s' % self.table) - l = self.cursor.fetchall() - self.assertEqual(len(l), self.rows) + self.cursor.execute("select * from %s" % self.table) + res = self.cursor.fetchall() + self.assertEqual(len(res), self.rows) for i in range(self.rows): for j in range(len(columndefs)): - self.assertEqual(l[i][j], generator(i,j)) - delete_statement = 'delete from %s where col1=%%s' % self.table + 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)) - l = self.cursor.fetchall() - self.assertFalse(l, "DELETE didn't work") + 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)) - l = self.cursor.fetchall() - self.assertTrue(len(l) == 1, "ROLLBACK didn't work") - self.cursor.execute('drop table %s' % (self.table)) + 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)) def test_truncation(self): - columndefs = ( 'col1 INT', 'col2 VARCHAR(255)') + columndefs = ("col1 INT", "col2 VARCHAR(255)") + def generator(row, col): - if col == 0: return row - else: return ('%i' % (row%10))*((255-self.rows//2)+row) + if col == 0: + return row + else: + return ("%i" % (row % 10)) * ((255 - self.rows // 2) + row) + self.create_table(columndefs) - insert_statement = ('INSERT INTO %s VALUES (%s)' % - (self.table, - ','.join(['%s'] * len(columndefs)))) + insert_statement = "INSERT INTO {} VALUES ({})".format( + self.table, + ",".join(["%s"] * len(columndefs)), + ) try: - self.cursor.execute(insert_statement, (0, '0'*256)) + self.cursor.execute(insert_statement, (0, "0" * 256)) except self.connection.DataError: pass else: - self.fail("Over-long column did not generate warnings/exception with single insert") + self.fail( + "Over-long column did not generate warnings/exception with single insert" # noqa: E501 + ) self.connection.rollback() @@ -156,143 +170,145 @@ def generator(row, col): for i in range(self.rows): data = [] for j in range(len(columndefs)): - data.append(generator(i,j)) - self.cursor.execute(insert_statement,tuple(data)) + data.append(generator(i, j)) + self.cursor.execute(insert_statement, tuple(data)) except self.connection.DataError: pass else: - self.fail("Over-long columns did not generate warnings/exception with execute()") + self.fail( + "Over-long columns did not generate warnings/exception with execute()" # noqa: E501 + ) self.connection.rollback() try: - data = [ [ generator(i,j) for j in range(len(columndefs)) ] - for i in range(self.rows) ] + data = [ + [generator(i, j) for j in range(len(columndefs))] + for i in range(self.rows) + ] self.cursor.executemany(insert_statement, data) except self.connection.DataError: pass else: - self.fail("Over-long columns did not generate warnings/exception with executemany()") + self.fail( + "Over-long columns did not generate warnings/exception with executemany()" # noqa: E501 + ) self.connection.rollback() - self.cursor.execute('drop table %s' % (self.table)) + self.cursor.execute("drop table %s" % (self.table)) def test_CHAR(self): # Character data - def generator(row,col): - return ('%i' % ((row+col) % 10)) * 255 - self.check_data_integrity( - ('col1 char(255)','col2 char(255)'), - generator) + def generator(row, col): + return ("%i" % ((row + col) % 10)) * 255 + + self.check_data_integrity(("col1 char(255)", "col2 char(255)"), generator) def test_INT(self): # Number data - def generator(row,col): - return row*row - self.check_data_integrity( - ('col1 INT',), - generator) + def generator(row, col): + return row * row + + self.check_data_integrity(("col1 INT",), generator) def test_DECIMAL(self): # DECIMAL from decimal import Decimal - def generator(row,col): + + def generator(row, col): return Decimal("%d.%02d" % (row, col)) - self.check_data_integrity( - ('col1 DECIMAL(5,2)',), - generator) - val = Decimal('1.11111111111111119E-7') - self.cursor.execute('SELECT %s', (val,)) + self.check_data_integrity(("col1 DECIMAL(5,2)",), generator) + + val = Decimal("1.11111111111111119E-7") + self.cursor.execute("SELECT %s", (val,)) result = self.cursor.fetchone()[0] self.assertEqual(result, val) self.assertIsInstance(result, Decimal) - self.cursor.execute('SELECT %s + %s', (Decimal('0.1'), Decimal('0.2'))) + self.cursor.execute("SELECT %s + %s", (Decimal("0.1"), Decimal("0.2"))) result = self.cursor.fetchone()[0] - self.assertEqual(result, Decimal('0.3')) + self.assertEqual(result, Decimal("0.3")) self.assertIsInstance(result, Decimal) def test_DATE(self): ticks = time() - def generator(row,col): - return self.db_module.DateFromTicks(ticks+row*86400-col*1313) - self.check_data_integrity( - ('col1 DATE',), - generator) + + def generator(row, col): + return self.db_module.DateFromTicks(ticks + row * 86400 - col * 1313) + + self.check_data_integrity(("col1 DATE",), generator) def test_TIME(self): ticks = time() - def generator(row,col): - return self.db_module.TimeFromTicks(ticks+row*86400-col*1313) - self.check_data_integrity( - ('col1 TIME',), - generator) + + def generator(row, col): + return self.db_module.TimeFromTicks(ticks + row * 86400 - col * 1313) + + self.check_data_integrity(("col1 TIME",), generator) def test_DATETIME(self): ticks = time() - def generator(row,col): - return self.db_module.TimestampFromTicks(ticks+row*86400-col*1313) - self.check_data_integrity( - ('col1 DATETIME',), - generator) + + def generator(row, col): + return self.db_module.TimestampFromTicks(ticks + row * 86400 - col * 1313) + + self.check_data_integrity(("col1 DATETIME",), generator) def test_TIMESTAMP(self): ticks = time() - def generator(row,col): - return self.db_module.TimestampFromTicks(ticks+row*86400-col*1313) - self.check_data_integrity( - ('col1 TIMESTAMP',), - generator) + + def generator(row, col): + return self.db_module.TimestampFromTicks(ticks + row * 86400 - col * 1313) + + self.check_data_integrity(("col1 TIMESTAMP",), generator) def test_fractional_TIMESTAMP(self): ticks = time() - def generator(row,col): - return self.db_module.TimestampFromTicks(ticks+row*86400-col*1313+row*0.7*col/3.0) - self.check_data_integrity( - ('col1 TIMESTAMP',), - generator) + + def generator(row, col): + return self.db_module.TimestampFromTicks( + ticks + row * 86400 - col * 1313 + row * 0.7 * col / 3.0 + ) + + self.check_data_integrity(("col1 TIMESTAMP",), generator) def test_LONG(self): - def generator(row,col): + def generator(row, col): if col == 0: return row else: - return self.BLOBUText # 'BLOB Text ' * 1024 - self.check_data_integrity( - ('col1 INT','col2 LONG'), - generator) + return self.BLOBUText # 'BLOB Text ' * 1024 + + self.check_data_integrity(("col1 INT", "col2 LONG"), generator) def test_TEXT(self): - def generator(row,col): - return self.BLOBUText # 'BLOB Text ' * 1024 - self.check_data_integrity( - ('col2 TEXT',), - generator) + def generator(row, col): + return self.BLOBUText # 'BLOB Text ' * 1024 + + self.check_data_integrity(("col2 TEXT",), generator) def test_LONG_BYTE(self): - def generator(row,col): + def generator(row, col): if col == 0: return row else: - return self.BLOBBinary # 'BLOB\000Binary ' * 1024 - self.check_data_integrity( - ('col1 INT','col2 LONG BYTE'), - generator) + return self.BLOBBinary # 'BLOB\000Binary ' * 1024 + + self.check_data_integrity(("col1 INT", "col2 LONG BYTE"), generator) def test_BLOB(self): - def generator(row,col): + def generator(row, col): if col == 0: return row else: - return self.BLOBBinary # 'BLOB\000Binary ' * 1024 - self.check_data_integrity( - ('col1 INT','col2 BLOB'), - generator) + return self.BLOBBinary # 'BLOB\000Binary ' * 1024 + + self.check_data_integrity(("col1 INT", "col2 BLOB"), generator) def test_DOUBLE(self): for val in (18014398509481982.0, 0.1): - self.cursor.execute('SELECT %s', (val,)); + self.cursor.execute("SELECT %s", (val,)) result = self.cursor.fetchone()[0] self.assertEqual(result, val) self.assertIsInstance(result, float) diff --git a/tests/configdb.py b/tests/configdb.py index 307cc3f4..c2949039 100644 --- a/tests/configdb.py +++ b/tests/configdb.py @@ -3,11 +3,11 @@ from os import environ, path tests_path = path.dirname(__file__) -conf_file = environ.get('TESTDB', 'default.cnf') +conf_file = environ.get("TESTDB", "default.cnf") conf_path = path.join(tests_path, conf_file) connect_kwargs = dict( - read_default_file = conf_path, - read_default_group = "MySQLdb-tests", + read_default_file=conf_path, + read_default_group="MySQLdb-tests", ) @@ -19,6 +19,7 @@ def connection_kwargs(kwargs): def connection_factory(**kwargs): import MySQLdb + db_kwargs = connection_kwargs(kwargs) db = MySQLdb.connect(**db_kwargs) return db diff --git a/tests/dbapi20.py b/tests/dbapi20.py index 79c188a5..be0f6292 100644 --- a/tests/dbapi20.py +++ b/tests/dbapi20.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -''' Python DB API 2.0 driver compliance unit test suite. +""" Python DB API 2.0 driver compliance unit test suite. This software is Public Domain and may be used without restrictions. @@ -9,11 +9,11 @@ this is turning out to be a thoroughly unwholesome unit test." -- Ian Bicking -''' +""" -__rcs_id__ = '$Id$' -__version__ = '$Revision$'[11:-2] -__author__ = 'Stuart Bishop ' +__rcs_id__ = "$Id$" +__version__ = "$Revision$"[11:-2] +__author__ = "Stuart Bishop " import unittest import time @@ -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 @@ -64,65 +64,66 @@ # - Fix bugs in test_setoutputsize_basic and test_setinputsizes # + class DatabaseAPI20Test(unittest.TestCase): - ''' Test a database self.driver for DB API 2.0 compatibility. - This implementation tests Gadfly, but the TestCase - is structured so that other self.drivers can subclass this - test case to ensure compiliance with the DB-API. It is - expected that this TestCase may be expanded in the future - if ambiguities or edge conditions are discovered. + """Test a database self.driver for DB API 2.0 compatibility. + This implementation tests Gadfly, but the TestCase + is structured so that other self.drivers can subclass this + test case to ensure compiliance with the DB-API. It is + expected that this TestCase may be expanded in the future + if ambiguities or edge conditions are discovered. - The 'Optional Extensions' are not yet being tested. + The 'Optional Extensions' are not yet being tested. - self.drivers should subclass this test, overriding setUp, tearDown, - self.driver, connect_args and connect_kw_args. Class specification - should be as follows: + self.drivers should subclass this test, overriding setUp, tearDown, + self.driver, connect_args and connect_kw_args. Class specification + should be as follows: - import dbapi20 - class mytest(dbapi20.DatabaseAPI20Test): - [...] + import dbapi20 + class mytest(dbapi20.DatabaseAPI20Test): + [...] - Don't 'import DatabaseAPI20Test from dbapi20', or you will - confuse the unit tester - just 'import dbapi20'. - ''' + Don't 'import DatabaseAPI20Test from dbapi20', or you will + confuse the unit tester - just 'import dbapi20'. + """ # The self.driver module. This should be the module where the 'connect' # method is to be found driver = None - connect_args = () # List of arguments to pass to connect - connect_kw_args = {} # Keyword arguments for connect - table_prefix = 'dbapi20test_' # If you need to specify a prefix for tables + connect_args = () # List of arguments to pass to connect + connect_kw_args = {} # Keyword arguments for connect + table_prefix = "dbapi20test_" # If you need to specify a prefix for tables - ddl1 = 'create table %sbooze (name varchar(20))' % table_prefix - ddl2 = 'create table %sbarflys (name varchar(20))' % table_prefix - xddl1 = 'drop table %sbooze' % table_prefix - xddl2 = 'drop table %sbarflys' % table_prefix + ddl1 = "create table %sbooze (name varchar(20))" % table_prefix + ddl2 = "create table %sbarflys (name varchar(20))" % table_prefix + xddl1 = "drop table %sbooze" % table_prefix + xddl2 = "drop table %sbarflys" % table_prefix - lowerfunc = 'lower' # Name of stored procedure to convert string->lowercase + lowerfunc = "lower" # Name of stored procedure to convert string->lowercase # Some drivers may need to override these helpers, for example adding # a 'commit' after the execute. - def executeDDL1(self,cursor): + def executeDDL1(self, cursor): cursor.execute(self.ddl1) - def executeDDL2(self,cursor): + def executeDDL2(self, cursor): cursor.execute(self.ddl2) def setUp(self): - ''' self.drivers should override this method to perform required setup - if any is necessary, such as creating the database. - ''' + """self.drivers should override this method to perform required setup + if any is necessary, such as creating the database. + """ pass def tearDown(self): - ''' self.drivers should override this method to perform required cleanup - if any is necessary, such as deleting the test database. - The default drops the tables that may be created. - ''' + """self.drivers should override this method to perform required cleanup + if any is necessary, such as deleting the test database. + The default drops the tables that may be created. + """ con = self._connect() try: cur = con.cursor() - for ddl in (self.xddl1,self.xddl2): + for ddl in (self.xddl1, self.xddl2): try: cur.execute(ddl) con.commit() @@ -135,9 +136,7 @@ def tearDown(self): def _connect(self): try: - return self.driver.connect( - *self.connect_args,**self.connect_kw_args - ) + return self.driver.connect(*self.connect_args, **self.connect_kw_args) except AttributeError: self.fail("No connect method found in self.driver module") @@ -150,7 +149,7 @@ def test_apilevel(self): # Must exist apilevel = self.driver.apilevel # Must equal 2.0 - self.assertEqual(apilevel,'2.0') + self.assertEqual(apilevel, "2.0") except AttributeError: self.fail("Driver doesn't define apilevel") @@ -159,7 +158,7 @@ def test_threadsafety(self): # Must exist threadsafety = self.driver.threadsafety # Must be a valid value - self.assertTrue(threadsafety in (0,1,2,3)) + self.assertTrue(threadsafety in (0, 1, 2, 3)) except AttributeError: self.fail("Driver doesn't define threadsafety") @@ -168,38 +167,24 @@ def test_paramstyle(self): # Must exist paramstyle = self.driver.paramstyle # Must be a valid value - self.assertTrue(paramstyle in ( - 'qmark','numeric','named','format','pyformat' - )) + self.assertTrue( + paramstyle in ("qmark", "numeric", "named", "format", "pyformat") + ) except AttributeError: self.fail("Driver doesn't define paramstyle") def test_Exceptions(self): # Make sure required exceptions exist, and are in the # 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) - ) - self.assertTrue( - issubclass(self.driver.DatabaseError,self.driver.Error) - ) - self.assertTrue( - issubclass(self.driver.OperationalError,self.driver.Error) - ) - self.assertTrue( - issubclass(self.driver.IntegrityError,self.driver.Error) - ) - self.assertTrue( - issubclass(self.driver.InternalError,self.driver.Error) - ) - self.assertTrue( - issubclass(self.driver.ProgrammingError,self.driver.Error) - ) - self.assertTrue( - issubclass(self.driver.NotSupportedError,self.driver.Error) - ) + self.assertTrue(issubclass(self.driver.Warning, Exception)) + self.assertTrue(issubclass(self.driver.Error, Exception)) + self.assertTrue(issubclass(self.driver.InterfaceError, self.driver.Error)) + self.assertTrue(issubclass(self.driver.DatabaseError, self.driver.Error)) + self.assertTrue(issubclass(self.driver.OperationalError, self.driver.Error)) + self.assertTrue(issubclass(self.driver.IntegrityError, self.driver.Error)) + self.assertTrue(issubclass(self.driver.InternalError, self.driver.Error)) + self.assertTrue(issubclass(self.driver.ProgrammingError, self.driver.Error)) + self.assertTrue(issubclass(self.driver.NotSupportedError, self.driver.Error)) def test_ExceptionsAsConnectionAttributes(self): # OPTIONAL EXTENSION @@ -220,7 +205,6 @@ def test_ExceptionsAsConnectionAttributes(self): self.assertTrue(con.ProgrammingError is drv.ProgrammingError) self.assertTrue(con.NotSupportedError is drv.NotSupportedError) - def test_commit(self): con = self._connect() try: @@ -233,7 +217,7 @@ def test_rollback(self): con = self._connect() # If rollback is defined, it should either work or throw # the documented exception - if hasattr(con,'rollback'): + if hasattr(con, "rollback"): try: con.rollback() except self.driver.NotSupportedError: @@ -242,7 +226,7 @@ def test_rollback(self): def test_cursor(self): con = self._connect() try: - cur = con.cursor() + _ = con.cursor() finally: con.close() @@ -254,14 +238,14 @@ def test_cursor_isolation(self): cur1 = con.cursor() cur2 = con.cursor() self.executeDDL1(cur1) - cur1.execute("insert into %sbooze values ('Victoria Bitter')" % ( - self.table_prefix - )) + cur1.execute( + "insert into %sbooze values ('Victoria Bitter')" % (self.table_prefix) + ) cur2.execute("select name from %sbooze" % self.table_prefix) booze = cur2.fetchall() - self.assertEqual(len(booze),1) - self.assertEqual(len(booze[0]),1) - self.assertEqual(booze[0][0],'Victoria Bitter') + self.assertEqual(len(booze), 1) + self.assertEqual(len(booze[0]), 1) + self.assertEqual(booze[0][0], "Victoria Bitter") finally: con.close() @@ -270,31 +254,41 @@ def test_description(self): try: cur = con.cursor() self.executeDDL1(cur) - self.assertEqual(cur.description,None, - 'cursor.description should be none after executing a ' - 'statement that can return no rows (such as DDL)' - ) - cur.execute('select name from %sbooze' % self.table_prefix) - self.assertEqual(len(cur.description),1, - 'cursor.description describes too many columns' - ) - self.assertEqual(len(cur.description[0]),7, - 'cursor.description[x] tuples must have 7 elements' - ) - self.assertEqual(cur.description[0][0].lower(),'name', - 'cursor.description[x][0] must return column name' - ) - self.assertEqual(cur.description[0][1],self.driver.STRING, - 'cursor.description[x][1] must return column type. Got %r' - % cur.description[0][1] - ) + self.assertEqual( + cur.description, + None, + "cursor.description should be none after executing a " + "statement that can return no rows (such as DDL)", + ) + cur.execute("select name from %sbooze" % self.table_prefix) + self.assertEqual( + len(cur.description), 1, "cursor.description describes too many columns" + ) + self.assertEqual( + len(cur.description[0]), + 7, + "cursor.description[x] tuples must have 7 elements", + ) + self.assertEqual( + cur.description[0][0].lower(), + "name", + "cursor.description[x][0] must return column name", + ) + self.assertEqual( + cur.description[0][1], + self.driver.STRING, + "cursor.description[x][1] must return column type. Got %r" + % cur.description[0][1], + ) # Make sure self.description gets reset self.executeDDL2(cur) - self.assertEqual(cur.description,None, - 'cursor.description not being set to None when executing ' - 'no-result statements (eg. DDL)' - ) + self.assertEqual( + cur.description, + None, + "cursor.description not being set to None when executing " + "no-result statements (eg. DDL)", + ) finally: con.close() @@ -303,47 +297,49 @@ def test_rowcount(self): try: cur = con.cursor() self.executeDDL1(cur) - self.assertEqual(cur.rowcount,-1, - 'cursor.rowcount should be -1 after executing no-result ' - 'statements' - ) - cur.execute("insert into %sbooze values ('Victoria Bitter')" % ( - self.table_prefix - )) - self.assertTrue(cur.rowcount in (-1,1), - 'cursor.rowcount should == number or rows inserted, or ' - 'set to -1 after executing an insert statement' - ) + self.assertEqual( + cur.rowcount, + -1, + "cursor.rowcount should be -1 after executing no-result " "statements", + ) + cur.execute( + "insert into %sbooze values ('Victoria Bitter')" % (self.table_prefix) + ) + self.assertTrue( + cur.rowcount in (-1, 1), + "cursor.rowcount should == number or rows inserted, or " + "set to -1 after executing an insert statement", + ) cur.execute("select name from %sbooze" % self.table_prefix) - self.assertTrue(cur.rowcount in (-1,1), - 'cursor.rowcount should == number of rows returned, or ' - 'set to -1 after executing a select statement' - ) + self.assertTrue( + cur.rowcount in (-1, 1), + "cursor.rowcount should == number of rows returned, or " + "set to -1 after executing a select statement", + ) self.executeDDL2(cur) - self.assertEqual(cur.rowcount,-1, - 'cursor.rowcount not being reset to -1 after executing ' - 'no-result statements' - ) + self.assertEqual( + cur.rowcount, + -1, + "cursor.rowcount not being reset to -1 after executing " + "no-result statements", + ) finally: con.close() - lower_func = 'lower' + lower_func = "lower" + def test_callproc(self): con = self._connect() try: cur = con.cursor() - if self.lower_func and hasattr(cur,'callproc'): - r = cur.callproc(self.lower_func,('FOO',)) - self.assertEqual(len(r),1) - self.assertEqual(r[0],'FOO') + if self.lower_func and hasattr(cur, "callproc"): + r = cur.callproc(self.lower_func, ("FOO",)) + self.assertEqual(len(r), 1) + self.assertEqual(r[0], "FOO") r = cur.fetchall() - self.assertEqual(len(r),1,'callproc produced no result set') - self.assertEqual(len(r[0]),1, - 'callproc produced invalid result set' - ) - self.assertEqual(r[0][0],'foo', - 'callproc produced invalid results' - ) + self.assertEqual(len(r), 1, "callproc produced no result set") + self.assertEqual(len(r[0]), 1, "callproc produced invalid result set") + self.assertEqual(r[0][0], "foo", "callproc produced invalid results") finally: con.close() @@ -356,14 +352,14 @@ def test_close(self): # cursor.execute should raise an Error if called after connection # closed - self.assertRaises(self.driver.Error,self.executeDDL1,cur) + self.assertRaises(self.driver.Error, self.executeDDL1, cur) # connection.commit should raise an Error if called after connection' # closed.' - self.assertRaises(self.driver.Error,con.commit) + self.assertRaises(self.driver.Error, con.commit) # connection.close should raise an Error if called more than once - self.assertRaises(self.driver.Error,con.close) + self.assertRaises(self.driver.Error, con.close) def test_execute(self): con = self._connect() @@ -373,105 +369,99 @@ def test_execute(self): finally: con.close() - def _paraminsert(self,cur): + def _paraminsert(self, cur): self.executeDDL1(cur) - cur.execute("insert into %sbooze values ('Victoria Bitter')" % ( - self.table_prefix - )) - self.assertTrue(cur.rowcount in (-1,1)) + cur.execute( + "insert into %sbooze values ('Victoria Bitter')" % (self.table_prefix) + ) + self.assertTrue(cur.rowcount in (-1, 1)) - if self.driver.paramstyle == 'qmark': + if self.driver.paramstyle == "qmark": cur.execute( - 'insert into %sbooze values (?)' % self.table_prefix, - ("Cooper's",) - ) - elif self.driver.paramstyle == 'numeric': + "insert into %sbooze values (?)" % self.table_prefix, ("Cooper's",) + ) + elif self.driver.paramstyle == "numeric": cur.execute( - 'insert into %sbooze values (:1)' % self.table_prefix, - ("Cooper's",) - ) - elif self.driver.paramstyle == 'named': + "insert into %sbooze values (:1)" % self.table_prefix, ("Cooper's",) + ) + elif self.driver.paramstyle == "named": cur.execute( - 'insert into %sbooze values (:beer)' % self.table_prefix, - {'beer':"Cooper's"} - ) - elif self.driver.paramstyle == 'format': + "insert into %sbooze values (:beer)" % self.table_prefix, + {"beer": "Cooper's"}, + ) + elif self.driver.paramstyle == "format": cur.execute( - 'insert into %sbooze values (%%s)' % self.table_prefix, - ("Cooper's",) - ) - elif self.driver.paramstyle == 'pyformat': + "insert into %sbooze values (%%s)" % self.table_prefix, ("Cooper's",) + ) + elif self.driver.paramstyle == "pyformat": cur.execute( - 'insert into %sbooze values (%%(beer)s)' % self.table_prefix, - {'beer':"Cooper's"} - ) + "insert into %sbooze values (%%(beer)s)" % self.table_prefix, + {"beer": "Cooper's"}, + ) else: - self.fail('Invalid paramstyle') - self.assertTrue(cur.rowcount in (-1,1)) + self.fail("Invalid paramstyle") + self.assertTrue(cur.rowcount in (-1, 1)) - cur.execute('select name from %sbooze' % self.table_prefix) + cur.execute("select name from %sbooze" % self.table_prefix) res = cur.fetchall() - self.assertEqual(len(res),2,'cursor.fetchall returned too few rows') - beers = [res[0][0],res[1][0]] + self.assertEqual(len(res), 2, "cursor.fetchall returned too few rows") + beers = [res[0][0], res[1][0]] beers.sort() - self.assertEqual(beers[0],"Cooper's", - 'cursor.fetchall retrieved incorrect data, or data inserted ' - 'incorrectly' - ) - self.assertEqual(beers[1],"Victoria Bitter", - 'cursor.fetchall retrieved incorrect data, or data inserted ' - 'incorrectly' - ) + self.assertEqual( + beers[0], + "Cooper's", + "cursor.fetchall retrieved incorrect data, or data inserted " "incorrectly", + ) + self.assertEqual( + beers[1], + "Victoria Bitter", + "cursor.fetchall retrieved incorrect data, or data inserted " "incorrectly", + ) def test_executemany(self): con = self._connect() try: cur = con.cursor() self.executeDDL1(cur) - largs = [ ("Cooper's",) , ("Boag's",) ] - margs = [ {'beer': "Cooper's"}, {'beer': "Boag's"} ] - if self.driver.paramstyle == 'qmark': + largs = [("Cooper's",), ("Boag's",)] + margs = [{"beer": "Cooper's"}, {"beer": "Boag's"}] + if self.driver.paramstyle == "qmark": cur.executemany( - 'insert into %sbooze values (?)' % self.table_prefix, - largs - ) - elif self.driver.paramstyle == 'numeric': + "insert into %sbooze values (?)" % self.table_prefix, largs + ) + elif self.driver.paramstyle == "numeric": cur.executemany( - 'insert into %sbooze values (:1)' % self.table_prefix, - largs - ) - elif self.driver.paramstyle == 'named': + "insert into %sbooze values (:1)" % self.table_prefix, largs + ) + elif self.driver.paramstyle == "named": cur.executemany( - 'insert into %sbooze values (:beer)' % self.table_prefix, - margs - ) - elif self.driver.paramstyle == 'format': + "insert into %sbooze values (:beer)" % self.table_prefix, margs + ) + elif self.driver.paramstyle == "format": cur.executemany( - 'insert into %sbooze values (%%s)' % self.table_prefix, - largs - ) - elif self.driver.paramstyle == 'pyformat': + "insert into %sbooze values (%%s)" % self.table_prefix, largs + ) + elif self.driver.paramstyle == "pyformat": cur.executemany( - 'insert into %sbooze values (%%(beer)s)' % ( - self.table_prefix - ), - margs - ) - else: - self.fail('Unknown paramstyle') - self.assertTrue(cur.rowcount in (-1,2), - 'insert using cursor.executemany set cursor.rowcount to ' - 'incorrect value %r' % cur.rowcount + "insert into %sbooze values (%%(beer)s)" % (self.table_prefix), + margs, ) - cur.execute('select name from %sbooze' % self.table_prefix) + else: + self.fail("Unknown paramstyle") + self.assertTrue( + cur.rowcount in (-1, 2), + "insert using cursor.executemany set cursor.rowcount to " + "incorrect value %r" % cur.rowcount, + ) + cur.execute("select name from %sbooze" % self.table_prefix) res = cur.fetchall() - self.assertEqual(len(res),2, - 'cursor.fetchall retrieved incorrect number of rows' - ) - beers = [res[0][0],res[1][0]] + self.assertEqual( + len(res), 2, "cursor.fetchall retrieved incorrect number of rows" + ) + beers = [res[0][0], res[1][0]] beers.sort() - self.assertEqual(beers[0],"Boag's",'incorrect data retrieved') - self.assertEqual(beers[1],"Cooper's",'incorrect data retrieved') + self.assertEqual(beers[0], "Boag's", "incorrect data retrieved") + self.assertEqual(beers[1], "Cooper's", "incorrect data retrieved") finally: con.close() @@ -482,59 +472,61 @@ def test_fetchone(self): # cursor.fetchone should raise an Error if called before # executing a select-type query - self.assertRaises(self.driver.Error,cur.fetchone) + self.assertRaises(self.driver.Error, cur.fetchone) # cursor.fetchone should raise an Error if called after # executing a query that cannot return rows self.executeDDL1(cur) - self.assertRaises(self.driver.Error,cur.fetchone) + self.assertRaises(self.driver.Error, cur.fetchone) - cur.execute('select name from %sbooze' % self.table_prefix) - self.assertEqual(cur.fetchone(),None, - 'cursor.fetchone should return None if a query retrieves ' - 'no rows' - ) - self.assertTrue(cur.rowcount in (-1,0)) + cur.execute("select name from %sbooze" % self.table_prefix) + self.assertEqual( + cur.fetchone(), + None, + "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 cannot return rows - cur.execute("insert into %sbooze values ('Victoria Bitter')" % ( - self.table_prefix - )) - self.assertRaises(self.driver.Error,cur.fetchone) + cur.execute( + "insert into %sbooze values ('Victoria Bitter')" % (self.table_prefix) + ) + self.assertRaises(self.driver.Error, cur.fetchone) - cur.execute('select name from %sbooze' % self.table_prefix) + cur.execute("select name from %sbooze" % self.table_prefix) r = cur.fetchone() - self.assertEqual(len(r),1, - 'cursor.fetchone should have retrieved a single row' - ) - self.assertEqual(r[0],'Victoria Bitter', - 'cursor.fetchone retrieved incorrect data' - ) - self.assertEqual(cur.fetchone(),None, - 'cursor.fetchone should return None if no more rows available' - ) - self.assertTrue(cur.rowcount in (-1,1)) + self.assertEqual( + len(r), 1, "cursor.fetchone should have retrieved a single row" + ) + self.assertEqual( + r[0], "Victoria Bitter", "cursor.fetchone retrieved incorrect data" + ) + self.assertEqual( + cur.fetchone(), + None, + "cursor.fetchone should return None if no more rows available", + ) + self.assertTrue(cur.rowcount in (-1, 1)) finally: con.close() samples = [ - 'Carlton Cold', - 'Carlton Draft', - 'Mountain Goat', - 'Redback', - 'Victoria Bitter', - 'XXXX' - ] + "Carlton Cold", + "Carlton Draft", + "Mountain Goat", + "Redback", + "Victoria Bitter", + "XXXX", + ] def _populate(self): - ''' Return a list of sql commands to setup the DB for the fetch - tests. - ''' + """Return a list of sql commands to setup the DB for the fetch + tests. + """ populate = [ - "insert into %sbooze values ('%s')" % (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 def test_fetchmany(self): @@ -543,78 +535,88 @@ def test_fetchmany(self): cur = con.cursor() # cursor.fetchmany should raise an Error if called without - #issuing a query - self.assertRaises(self.driver.Error,cur.fetchmany,4) + # issuing a query + self.assertRaises(self.driver.Error, cur.fetchmany, 4) self.executeDDL1(cur) for sql in self._populate(): cur.execute(sql) - cur.execute('select name from %sbooze' % self.table_prefix) + cur.execute("select name from %sbooze" % self.table_prefix) r = cur.fetchmany() - self.assertEqual(len(r),1, - 'cursor.fetchmany retrieved incorrect number of rows, ' - 'default of arraysize is one.' - ) - cur.arraysize=10 - r = cur.fetchmany(3) # Should get 3 rows - self.assertEqual(len(r),3, - 'cursor.fetchmany retrieved incorrect number of rows' - ) - r = cur.fetchmany(4) # Should get 2 more - self.assertEqual(len(r),2, - 'cursor.fetchmany retrieved incorrect number of rows' - ) - r = cur.fetchmany(4) # Should be an empty sequence - self.assertEqual(len(r),0, - 'cursor.fetchmany should return an empty sequence after ' - 'results are exhausted' + self.assertEqual( + len(r), + 1, + "cursor.fetchmany retrieved incorrect number of rows, " + "default of arraysize is one.", + ) + cur.arraysize = 10 + r = cur.fetchmany(3) # Should get 3 rows + self.assertEqual( + len(r), 3, "cursor.fetchmany retrieved incorrect number of rows" + ) + r = cur.fetchmany(4) # Should get 2 more + self.assertEqual( + len(r), 2, "cursor.fetchmany retrieved incorrect number of rows" ) - self.assertTrue(cur.rowcount in (-1,6)) + r = cur.fetchmany(4) # Should be an empty sequence + self.assertEqual( + len(r), + 0, + "cursor.fetchmany should return an empty sequence after " + "results are exhausted", + ) + self.assertTrue(cur.rowcount in (-1, 6)) # Same as above, using cursor.arraysize - cur.arraysize=4 - cur.execute('select name from %sbooze' % self.table_prefix) - r = cur.fetchmany() # Should get 4 rows - self.assertEqual(len(r),4, - 'cursor.arraysize not being honoured by fetchmany' - ) - r = cur.fetchmany() # Should get 2 more - self.assertEqual(len(r),2) - r = cur.fetchmany() # Should be an empty sequence - self.assertEqual(len(r),0) - self.assertTrue(cur.rowcount in (-1,6)) - - cur.arraysize=6 - cur.execute('select name from %sbooze' % self.table_prefix) - rows = cur.fetchmany() # Should get all rows - self.assertTrue(cur.rowcount in (-1,6)) - self.assertEqual(len(rows),6) - self.assertEqual(len(rows),6) + cur.arraysize = 4 + cur.execute("select name from %sbooze" % self.table_prefix) + r = cur.fetchmany() # Should get 4 rows + self.assertEqual( + len(r), 4, "cursor.arraysize not being honoured by fetchmany" + ) + r = cur.fetchmany() # Should get 2 more + self.assertEqual(len(r), 2) + r = cur.fetchmany() # Should be an empty sequence + self.assertEqual(len(r), 0) + self.assertTrue(cur.rowcount in (-1, 6)) + + cur.arraysize = 6 + cur.execute("select name from %sbooze" % self.table_prefix) + rows = cur.fetchmany() # Should get all rows + self.assertTrue(cur.rowcount in (-1, 6)) + self.assertEqual(len(rows), 6) + self.assertEqual(len(rows), 6) rows = [r[0] for r in rows] rows.sort() # Make sure we get the right data back out - for i in range(0,6): - self.assertEqual(rows[i],self.samples[i], - 'incorrect data retrieved by cursor.fetchmany' - ) - - rows = cur.fetchmany() # Should return an empty list - self.assertEqual(len(rows),0, - 'cursor.fetchmany should return an empty sequence if ' - 'called after the whole result set has been fetched' + for i in range(0, 6): + self.assertEqual( + rows[i], + self.samples[i], + "incorrect data retrieved by cursor.fetchmany", ) - self.assertTrue(cur.rowcount in (-1,6)) + + rows = cur.fetchmany() # Should return an empty list + self.assertEqual( + len(rows), + 0, + "cursor.fetchmany should return an empty sequence if " + "called after the whole result set has been fetched", + ) + self.assertTrue(cur.rowcount in (-1, 6)) self.executeDDL2(cur) - cur.execute('select name from %sbarflys' % self.table_prefix) - r = cur.fetchmany() # Should get empty sequence - self.assertEqual(len(r),0, - 'cursor.fetchmany should return an empty sequence if ' - 'query retrieved no rows' - ) - self.assertTrue(cur.rowcount in (-1,0)) + cur.execute("select name from %sbarflys" % self.table_prefix) + r = cur.fetchmany() # Should get empty sequence + self.assertEqual( + len(r), + 0, + "cursor.fetchmany should return an empty sequence if " + "query retrieved no rows", + ) + self.assertTrue(cur.rowcount in (-1, 0)) finally: con.close() @@ -634,36 +636,41 @@ def test_fetchall(self): # cursor.fetchall should raise an Error if called # after executing a statement that cannot return rows - self.assertRaises(self.driver.Error,cur.fetchall) + self.assertRaises(self.driver.Error, cur.fetchall) - cur.execute('select name from %sbooze' % self.table_prefix) + cur.execute("select name from %sbooze" % self.table_prefix) rows = cur.fetchall() - self.assertTrue(cur.rowcount in (-1,len(self.samples))) - self.assertEqual(len(rows),len(self.samples), - 'cursor.fetchall did not retrieve all rows' - ) + self.assertTrue(cur.rowcount in (-1, len(self.samples))) + self.assertEqual( + len(rows), + len(self.samples), + "cursor.fetchall did not retrieve all rows", + ) rows = [r[0] for r in rows] rows.sort() - for i in range(0,len(self.samples)): - self.assertEqual(rows[i],self.samples[i], - 'cursor.fetchall retrieved incorrect rows' + for i in range(0, len(self.samples)): + self.assertEqual( + rows[i], self.samples[i], "cursor.fetchall retrieved incorrect rows" ) rows = cur.fetchall() self.assertEqual( - len(rows),0, - 'cursor.fetchall should return an empty list if called ' - 'after the whole result set has been fetched' - ) - self.assertTrue(cur.rowcount in (-1,len(self.samples))) + len(rows), + 0, + "cursor.fetchall should return an empty list if called " + "after the whole result set has been fetched", + ) + self.assertTrue(cur.rowcount in (-1, len(self.samples))) self.executeDDL2(cur) - cur.execute('select name from %sbarflys' % self.table_prefix) + cur.execute("select name from %sbarflys" % self.table_prefix) rows = cur.fetchall() - self.assertTrue(cur.rowcount in (-1,0)) - self.assertEqual(len(rows),0, - 'cursor.fetchall should return an empty list if ' - 'a select query returns no rows' - ) + self.assertTrue(cur.rowcount in (-1, 0)) + self.assertEqual( + len(rows), + 0, + "cursor.fetchall should return an empty list if " + "a select query returns no rows", + ) finally: con.close() @@ -676,91 +683,91 @@ def test_mixedfetch(self): for sql in self._populate(): cur.execute(sql) - cur.execute('select name from %sbooze' % self.table_prefix) - rows1 = cur.fetchone() + cur.execute("select name from %sbooze" % self.table_prefix) + rows1 = cur.fetchone() rows23 = cur.fetchmany(2) - rows4 = cur.fetchone() + rows4 = cur.fetchone() rows56 = cur.fetchall() - self.assertTrue(cur.rowcount in (-1,6)) - self.assertEqual(len(rows23),2, - 'fetchmany returned incorrect number of rows' - ) - self.assertEqual(len(rows56),2, - 'fetchall returned incorrect number of rows' - ) + self.assertTrue(cur.rowcount in (-1, 6)) + self.assertEqual( + len(rows23), 2, "fetchmany returned incorrect number of rows" + ) + self.assertEqual( + len(rows56), 2, "fetchall returned incorrect number of rows" + ) rows = [rows1[0]] - rows.extend([rows23[0][0],rows23[1][0]]) + rows.extend([rows23[0][0], rows23[1][0]]) rows.append(rows4[0]) - rows.extend([rows56[0][0],rows56[1][0]]) + rows.extend([rows56[0][0], rows56[1][0]]) rows.sort() - for i in range(0,len(self.samples)): - self.assertEqual(rows[i],self.samples[i], - 'incorrect data retrieved or inserted' - ) + for i in range(0, len(self.samples)): + self.assertEqual( + rows[i], self.samples[i], "incorrect data retrieved or inserted" + ) finally: con.close() - def help_nextset_setUp(self,cur): - ''' Should create a procedure called deleteme - that returns two result sets, first the - number of rows in booze then "name from booze" - ''' - raise NotImplementedError('Helper not implemented') - #sql=""" + def help_nextset_setUp(self, cur): + """Should create a procedure called deleteme + that returns two result sets, first the + number of rows in booze then "name from booze" + """ + raise NotImplementedError("Helper not implemented") + # sql=""" # create procedure deleteme as # begin # select count(*) from booze # select name from booze # end - #""" - #cur.execute(sql) + # """ + # cur.execute(sql) - def help_nextset_tearDown(self,cur): - 'If cleaning up is needed after nextSetTest' - raise NotImplementedError('Helper not implemented') - #cur.execute("drop procedure deleteme") + def help_nextset_tearDown(self, cur): + "If cleaning up is needed after nextSetTest" + raise NotImplementedError("Helper not implemented") + # cur.execute("drop procedure deleteme") def test_nextset(self): con = self._connect() try: cur = con.cursor() - if not hasattr(cur,'nextset'): + if not hasattr(cur, "nextset"): return try: self.executeDDL1(cur) - sql=self._populate() + sql = self._populate() for sql in self._populate(): cur.execute(sql) self.help_nextset_setUp(cur) - cur.callproc('deleteme') - numberofrows=cur.fetchone() - assert numberofrows[0]== len(self.samples) + cur.callproc("deleteme") + numberofrows = cur.fetchone() + assert numberofrows[0] == len(self.samples) assert cur.nextset() - names=cur.fetchall() + names = cur.fetchall() assert len(names) == len(self.samples) - s=cur.nextset() - assert s == None,'No more return sets, should return None' + s = cur.nextset() + assert s is None, "No more return sets, should return None" finally: self.help_nextset_tearDown(cur) finally: con.close() - def test_nextset(self): - raise NotImplementedError('Drivers need to override this test') + def test_nextset(self): # noqa: F811 + raise NotImplementedError("Drivers need to override this test") def test_arraysize(self): # Not much here - rest of the tests for this are in test_fetchmany con = self._connect() try: cur = con.cursor() - self.assertTrue(hasattr(cur,'arraysize'), - 'cursor.arraysize must be defined' - ) + self.assertTrue( + hasattr(cur, "arraysize"), "cursor.arraysize must be defined" + ) finally: con.close() @@ -768,8 +775,8 @@ def test_setinputsizes(self): con = self._connect() try: cur = con.cursor() - cur.setinputsizes( (25,) ) - self._paraminsert(cur) # Make sure cursor still works + cur.setinputsizes((25,)) + self._paraminsert(cur) # Make sure cursor still works finally: con.close() @@ -779,75 +786,74 @@ def test_setoutputsize_basic(self): try: cur = con.cursor() cur.setoutputsize(1000) - cur.setoutputsize(2000,0) - self._paraminsert(cur) # Make sure the cursor still works + cur.setoutputsize(2000, 0) + self._paraminsert(cur) # Make sure the cursor still works finally: con.close() def test_setoutputsize(self): - # Real test for setoutputsize is driver dependant - raise NotImplementedError('Driver need to override this test') + # Real test for setoutputsize is driver dependent + raise NotImplementedError("Driver need to override this test") def test_None(self): con = self._connect() try: cur = con.cursor() self.executeDDL1(cur) - cur.execute('insert into %sbooze values (NULL)' % self.table_prefix) - cur.execute('select name from %sbooze' % self.table_prefix) + cur.execute("insert into %sbooze values (NULL)" % self.table_prefix) + cur.execute("select name from %sbooze" % self.table_prefix) r = cur.fetchall() - self.assertEqual(len(r),1) - self.assertEqual(len(r[0]),1) - self.assertEqual(r[0][0],None,'NULL value not returned as None') + self.assertEqual(len(r), 1) + self.assertEqual(len(r[0]), 1) + self.assertEqual(r[0][0], None, "NULL value not returned as None") finally: 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))) + d1 = self.driver.Date(2002, 12, 25) # noqa F841 + d2 = self.driver.DateFromTicks( # noqa F841 + 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))) + t1 = self.driver.Time(13, 45, 30) # noqa F841 + t2 = self.driver.TimeFromTicks( # noqa F841 + 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)) - ) + t1 = self.driver.Timestamp(2002, 12, 25, 13, 45, 30) # noqa F841 + t2 = self.driver.TimestampFromTicks( # noqa F841 + 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'') + b = self.driver.Binary(b"Something") + b = self.driver.Binary(b"") # noqa F841 def test_STRING(self): - self.assertTrue(hasattr(self.driver,'STRING'), - 'module.STRING must be defined' - ) + self.assertTrue(hasattr(self.driver, "STRING"), "module.STRING must be defined") def test_BINARY(self): - self.assertTrue(hasattr(self.driver,'BINARY'), - 'module.BINARY must be defined.' - ) + self.assertTrue( + hasattr(self.driver, "BINARY"), "module.BINARY must be defined." + ) def test_NUMBER(self): - self.assertTrue(hasattr(self.driver,'NUMBER'), - 'module.NUMBER must be defined.' - ) + self.assertTrue( + hasattr(self.driver, "NUMBER"), "module.NUMBER must be defined." + ) def test_DATETIME(self): - self.assertTrue(hasattr(self.driver,'DATETIME'), - 'module.DATETIME must be defined.' - ) + self.assertTrue( + hasattr(self.driver, "DATETIME"), "module.DATETIME must be defined." + ) def test_ROWID(self): - self.assertTrue(hasattr(self.driver,'ROWID'), - 'module.ROWID must be defined.' - ) - + self.assertTrue(hasattr(self.driver, "ROWID"), "module.ROWID must be defined.") 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 6e39d146..dbff27c2 100644 --- a/tests/test_MySQLdb_capabilities.py +++ b/tests/test_MySQLdb_capabilities.py @@ -1,24 +1,22 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- import capabilities from datetime import timedelta from contextlib import closing import unittest import MySQLdb -from MySQLdb.compat import unicode -from MySQLdb import cursors from configdb import connection_factory import warnings -warnings.filterwarnings('ignore') +warnings.filterwarnings("ignore") class test_MySQLdb(capabilities.DatabaseTest): - db_module = MySQLdb connect_args = () - connect_kwargs = dict(use_unicode=True, sql_mode="ANSI,STRICT_TRANS_TABLES,TRADITIONAL") + connect_kwargs = dict( + use_unicode=True, sql_mode="ANSI,STRICT_TRANS_TABLES,TRADITIONAL" + ) create_table_extra = "ENGINE=INNODB CHARACTER SET UTF8" leak_test = False @@ -26,99 +24,115 @@ def quote_identifier(self, ident): return "`%s`" % ident def test_TIME(self): - def generator(row,col): - return timedelta(0, row*8000) - self.check_data_integrity( - ('col1 TIME',), - generator) + def generator(row, col): + return timedelta(0, row * 8000) + + self.check_data_integrity(("col1 TIME",), generator) def test_TINYINT(self): # Number data def generator(row, col): - v = (row*row) % 256 + v = (row * row) % 256 if v > 127: - v = v-256 + v = v - 256 return v - self.check_data_integrity( - ('col1 TINYINT',), - generator) + + self.check_data_integrity(("col1 TINYINT",), generator) def test_stored_procedures(self): db = self.connection c = self.cursor - self.create_table(('pos INT', 'tree CHAR(20)')) - c.executemany("INSERT INTO %s (pos,tree) VALUES (%%s,%%s)" % self.table, - list(enumerate('ash birch cedar Lärche pine'.split()))) + self.create_table(("pos INT", "tree CHAR(20)")) + c.executemany( + "INSERT INTO %s (pos,tree) VALUES (%%s,%%s)" % self.table, + list(enumerate("ash birch cedar Lärche pine".split())), + ) db.commit() - c.execute(""" + c.execute( + """ CREATE PROCEDURE test_sp(IN t VARCHAR(255)) BEGIN SELECT pos FROM %s WHERE tree = t; END - """ % self.table) + """ + % self.table + ) db.commit() - c.callproc('test_sp', ('Lärche',)) + c.callproc("test_sp", ("Lärche",)) rows = c.fetchall() self.assertEqual(len(rows), 1) self.assertEqual(rows[0][0], 3) c.nextset() c.execute("DROP PROCEDURE test_sp") - c.execute('drop table %s' % (self.table)) + c.execute("drop table %s" % (self.table)) def test_small_CHAR(self): # Character data - def generator(row,col): - i = (row*col+62)%256 - if i == 62: return '' - if i == 63: return None + def generator(row, col): + i = (row * col + 62) % 256 + if i == 62: + return "" + if i == 63: + return None return chr(i) - self.check_data_integrity( - ('col1 char(1)','col2 char(1)'), - generator) + + self.check_data_integrity(("col1 char(1)", "col2 char(1)"), generator) def test_BIT(self): c = self.cursor try: - c.execute("""create table test_BIT ( + c.execute( + """create table test_BIT ( b3 BIT(3), b7 BIT(10), - b64 BIT(64))""") + b64 BIT(64))""" + ) - one64 = '1'*64 + one64 = "1" * 64 c.execute( "insert into test_BIT (b3, b7, b64)" - " VALUES (b'011', b'1111111111', b'%s')" - % one64) + " VALUES (b'011', b'1111111111', b'%s')" % one64 + ) c.execute("SELECT b3, b7, b64 FROM test_BIT") row = c.fetchone() - self.assertEqual(row[0], b'\x03') - self.assertEqual(row[1], b'\x03\xff') - self.assertEqual(row[2], b'\xff'*8) + self.assertEqual(row[0], b"\x03") + self.assertEqual(row[1], b"\x03\xff") + self.assertEqual(row[2], b"\xff" * 8) finally: c.execute("drop table if exists test_BIT") def test_MULTIPOLYGON(self): c = self.cursor try: - c.execute("""create table test_MULTIPOLYGON ( + c.execute( + """create table test_MULTIPOLYGON ( id INTEGER PRIMARY KEY, - border MULTIPOLYGON)""") + border MULTIPOLYGON)""" + ) c.execute( - "insert into test_MULTIPOLYGON (id, border)" - " VALUES (1, GeomFromText('MULTIPOLYGON(((1 1, 1 -1, -1 -1, -1 1, 1 1)),((1 1, 3 1, 3 3, 1 3, 1 1)))'))" + """ +INSERT INTO test_MULTIPOLYGON + (id, border) +VALUES (1, + ST_Geomfromtext( +'MULTIPOLYGON(((1 1, 1 -1, -1 -1, -1 1, 1 1)),((1 1, 3 1, 3 3, 1 3, 1 1)))')) +""" ) - c.execute("SELECT id, AsText(border) FROM test_MULTIPOLYGON") + c.execute("SELECT id, ST_AsText(border) FROM test_MULTIPOLYGON") row = c.fetchone() self.assertEqual(row[0], 1) - self.assertEqual(row[1], 'MULTIPOLYGON(((1 1,1 -1,-1 -1,-1 1,1 1)),((1 1,3 1,3 3,1 3,1 1)))') + self.assertEqual( + row[1], + "MULTIPOLYGON(((1 1,1 -1,-1 -1,-1 1,1 1)),((1 1,3 1,3 3,1 3,1 1)))", + ) - c.execute("SELECT id, AsWKB(border) FROM test_MULTIPOLYGON") + c.execute("SELECT id, ST_AsWKB(border) FROM test_MULTIPOLYGON") row = c.fetchone() self.assertEqual(row[0], 1) self.assertNotEqual(len(row[1]), 0) @@ -132,19 +146,21 @@ def test_MULTIPOLYGON(self): def test_bug_2671682(self): from MySQLdb.constants import ER + try: - self.cursor.execute("describe some_non_existent_table"); + self.cursor.execute("describe some_non_existent_table") except self.connection.ProgrammingError as msg: self.assertTrue(str(ER.NO_SUCH_TABLE) in str(msg)) def test_bug_3514287(self): c = self.cursor try: - c.execute("""create table bug_3541287 ( + c.execute( + """create table bug_3541287 ( c1 CHAR(10), - t1 TIMESTAMP)""") - c.execute("insert into bug_3541287 (c1,t1) values (%s, NOW())", - ("blah",)) + t1 TIMESTAMP)""" + ) + c.execute("insert into bug_3541287 (c1,t1) values (%s, NOW())", ("blah",)) finally: c.execute("drop table if exists bug_3541287") @@ -165,22 +181,25 @@ def test_binary_prefix(self): for binary_prefix in (True, False, None): kwargs = self.connect_kwargs.copy() # needs to be set to can guarantee CHARSET response for normal strings - kwargs['charset'] = 'utf8' - if binary_prefix != None: - kwargs['binary_prefix'] = binary_prefix + kwargs["charset"] = "utf8mb4" + if binary_prefix is not None: + kwargs["binary_prefix"] = binary_prefix with closing(connection_factory(**kwargs)) as conn: with closing(conn.cursor()) as c: - c.execute('SELECT CHARSET(%s)', (MySQLdb.Binary(b'raw bytes'),)) - self.assertEqual(c.fetchall()[0][0], 'binary' if binary_prefix else 'utf8') + c.execute("SELECT CHARSET(%s)", (MySQLdb.Binary(b"raw bytes"),)) + self.assertEqual( + c.fetchall()[0][0], "binary" if binary_prefix else "utf8mb4" + ) # normal strings should not get prefix - c.execute('SELECT CHARSET(%s)', ('str',)) - self.assertEqual(c.fetchall()[0][0], 'utf8') + c.execute("SELECT CHARSET(%s)", ("str",)) + self.assertEqual(c.fetchall()[0][0], "utf8mb4") -if __name__ == '__main__': +if __name__ == "__main__": if test_MySQLdb.leak_test: import gc + gc.enable() gc.set_debug(gc.DEBUG_LEAK) unittest.main() diff --git a/tests/test_MySQLdb_dbapi20.py b/tests/test_MySQLdb_dbapi20.py index 1e808bd1..a0dd92a1 100644 --- a/tests/test_MySQLdb_dbapi20.py +++ b/tests/test_MySQLdb_dbapi20.py @@ -4,17 +4,22 @@ import MySQLdb from configdb import connection_kwargs import warnings + warnings.simplefilter("ignore") class test_MySQLdb(dbapi20.DatabaseAPI20Test): driver = MySQLdb connect_args = () - connect_kw_args = connection_kwargs(dict(sql_mode="ANSI,STRICT_TRANS_TABLES,TRADITIONAL")) + connect_kw_args = connection_kwargs( + dict(sql_mode="ANSI,STRICT_TRANS_TABLES,TRADITIONAL") + ) + + def test_setoutputsize(self): + pass - def test_setoutputsize(self): pass - def test_setoutputsize_basic(self): pass - def test_nextset(self): pass + def test_setoutputsize_basic(self): + pass """The tests on fetchone and fetchall and rowcount bogusly test for an exception if the statement cannot return a @@ -36,36 +41,41 @@ def test_fetchall(self): # cursor.fetchall should raise an Error if called # after executing a statement that cannot return rows - #self.assertRaises(self.driver.Error,cur.fetchall) + # self.assertRaises(self.driver.Error,cur.fetchall) - cur.execute('select name from %sbooze' % self.table_prefix) + cur.execute("select name from %sbooze" % self.table_prefix) rows = cur.fetchall() - self.assertTrue(cur.rowcount in (-1,len(self.samples))) - self.assertEqual(len(rows),len(self.samples), - 'cursor.fetchall did not retrieve all rows' - ) + self.assertTrue(cur.rowcount in (-1, len(self.samples))) + self.assertEqual( + len(rows), + len(self.samples), + "cursor.fetchall did not retrieve all rows", + ) rows = [r[0] for r in rows] rows.sort() - for i in range(0,len(self.samples)): - self.assertEqual(rows[i],self.samples[i], - 'cursor.fetchall retrieved incorrect rows' + for i in range(0, len(self.samples)): + self.assertEqual( + rows[i], self.samples[i], "cursor.fetchall retrieved incorrect rows" ) rows = cur.fetchall() self.assertEqual( - len(rows),0, - 'cursor.fetchall should return an empty list if called ' - 'after the whole result set has been fetched' - ) - self.assertTrue(cur.rowcount in (-1,len(self.samples))) + len(rows), + 0, + "cursor.fetchall should return an empty list if called " + "after the whole result set has been fetched", + ) + self.assertTrue(cur.rowcount in (-1, len(self.samples))) self.executeDDL2(cur) - cur.execute('select name from %sbarflys' % self.table_prefix) + cur.execute("select name from %sbarflys" % self.table_prefix) rows = cur.fetchall() - self.assertTrue(cur.rowcount in (-1,0)) - self.assertEqual(len(rows),0, - 'cursor.fetchall should return an empty list if ' - 'a select query returns no rows' - ) + self.assertTrue(cur.rowcount in (-1, 0)) + self.assertEqual( + len(rows), + 0, + "cursor.fetchall should return an empty list if " + "a select query returns no rows", + ) finally: con.close() @@ -77,39 +87,42 @@ def test_fetchone(self): # cursor.fetchone should raise an Error if called before # executing a select-type query - self.assertRaises(self.driver.Error,cur.fetchone) + self.assertRaises(self.driver.Error, cur.fetchone) # cursor.fetchone should raise an Error if called after # executing a query that cannot return rows self.executeDDL1(cur) -## self.assertRaises(self.driver.Error,cur.fetchone) + # self.assertRaises(self.driver.Error,cur.fetchone) - cur.execute('select name from %sbooze' % self.table_prefix) - self.assertEqual(cur.fetchone(),None, - 'cursor.fetchone should return None if a query retrieves ' - 'no rows' - ) - self.assertTrue(cur.rowcount in (-1,0)) + cur.execute("select name from %sbooze" % self.table_prefix) + self.assertEqual( + cur.fetchone(), + None, + "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 cannot return rows - cur.execute("insert into %sbooze values ('Victoria Bitter')" % ( - self.table_prefix - )) -## self.assertRaises(self.driver.Error,cur.fetchone) + cur.execute( + "insert into %sbooze values ('Victoria Bitter')" % (self.table_prefix) + ) + # self.assertRaises(self.driver.Error,cur.fetchone) - cur.execute('select name from %sbooze' % self.table_prefix) + cur.execute("select name from %sbooze" % self.table_prefix) r = cur.fetchone() - self.assertEqual(len(r),1, - 'cursor.fetchone should have retrieved a single row' - ) - self.assertEqual(r[0],'Victoria Bitter', - 'cursor.fetchone retrieved incorrect data' - ) -## self.assertEqual(cur.fetchone(),None, -## 'cursor.fetchone should return None if no more rows available' -## ) - self.assertTrue(cur.rowcount in (-1,1)) + self.assertEqual( + len(r), 1, "cursor.fetchone should have retrieved a single row" + ) + self.assertEqual( + r[0], "Victoria Bitter", "cursor.fetchone retrieved incorrect data" + ) + # self.assertEqual( + # cur.fetchone(), + # None, + # "cursor.fetchone should return None if no more rows available", + # ) + self.assertTrue(cur.rowcount in (-1, 1)) finally: con.close() @@ -119,81 +132,94 @@ def test_rowcount(self): try: cur = con.cursor() self.executeDDL1(cur) -## self.assertEqual(cur.rowcount,-1, -## 'cursor.rowcount should be -1 after executing no-result ' -## 'statements' -## ) - cur.execute("insert into %sbooze values ('Victoria Bitter')" % ( - self.table_prefix - )) -## self.assertTrue(cur.rowcount in (-1,1), -## 'cursor.rowcount should == number or rows inserted, or ' -## 'set to -1 after executing an insert statement' -## ) + # self.assertEqual(cur.rowcount,-1, + # 'cursor.rowcount should be -1 after executing no-result ' + # 'statements' + # ) + cur.execute( + "insert into %sbooze values ('Victoria Bitter')" % (self.table_prefix) + ) + # self.assertTrue(cur.rowcount in (-1,1), + # 'cursor.rowcount should == number or rows inserted, or ' + # 'set to -1 after executing an insert statement' + # ) cur.execute("select name from %sbooze" % self.table_prefix) - self.assertTrue(cur.rowcount in (-1,1), - 'cursor.rowcount should == number of rows returned, or ' - 'set to -1 after executing a select statement' - ) + self.assertTrue( + cur.rowcount in (-1, 1), + "cursor.rowcount should == number of rows returned, or " + "set to -1 after executing a select statement", + ) self.executeDDL2(cur) -## self.assertEqual(cur.rowcount,-1, -## 'cursor.rowcount not being reset to -1 after executing ' -## 'no-result statements' -## ) + # self.assertEqual(cur.rowcount,-1, + # 'cursor.rowcount not being reset to -1 after executing ' + # 'no-result statements' + # ) finally: con.close() def test_callproc(self): - pass # performed in test_MySQL_capabilities - - def help_nextset_setUp(self,cur): - ''' Should create a procedure called deleteme - that returns two result sets, first the - number of rows in booze then "name from booze" - ''' - sql=""" + pass # performed in test_MySQL_capabilities + + def help_nextset_setUp(self, cur): + """ + Should create a procedure called deleteme + that returns two result sets, first the + number of rows in booze then "name from booze" + """ + sql = """ create procedure deleteme() begin select count(*) from %(tp)sbooze; select name from %(tp)sbooze; end - """ % dict(tp=self.table_prefix) + """ % dict( + tp=self.table_prefix + ) cur.execute(sql) - def help_nextset_tearDown(self,cur): - 'If cleaning up is needed after nextSetTest' + def help_nextset_tearDown(self, cur): + "If cleaning up is needed after nextSetTest" cur.execute("drop procedure deleteme") def test_nextset(self): - #from warnings import warn + # from warnings import warn + con = self._connect() try: cur = con.cursor() - if not hasattr(cur, 'nextset'): + if not hasattr(cur, "nextset"): return try: self.executeDDL1(cur) - sql=self._populate() + sql = self._populate() for sql in self._populate(): cur.execute(sql) self.help_nextset_setUp(cur) - cur.callproc('deleteme') - numberofrows=cur.fetchone() - assert numberofrows[0]== len(self.samples) + cur.callproc("deleteme") + numberofrows = cur.fetchone() + assert numberofrows[0] == len(self.samples) assert cur.nextset() - names=cur.fetchall() + names = cur.fetchall() assert len(names) == len(self.samples) - s=cur.nextset() + s = cur.nextset() if s: empty = cur.fetchall() - self.assertEqual(len(empty), 0, - "non-empty result set after other result sets") - #warn("Incompatibility: MySQL returns an empty result set for the CALL itself", - # Warning) - #assert s == None,'No more return sets, should return None' + self.assertEqual( + len(empty), 0, "non-empty result set after other result sets" + ) + # warn( + # ": ".join( + # [ + # "Incompatibility", + # "MySQL returns an empty result set for the CALL itself" + # ] + # ), + # Warning, + # ) + # assert s == None, "No more return sets, should return None" finally: self.help_nextset_tearDown(cur) @@ -201,5 +227,5 @@ def test_nextset(self): con.close() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_MySQLdb_nonstandard.py b/tests/test_MySQLdb_nonstandard.py index ea05079f..5e841791 100644 --- a/tests/test_MySQLdb_nonstandard.py +++ b/tests/test_MySQLdb_nonstandard.py @@ -5,6 +5,7 @@ from MySQLdb.constants import FIELD_TYPE from configdb import connection_factory import warnings + warnings.simplefilter("ignore") @@ -36,10 +37,12 @@ def test_client_info(self): self.assertTrue(isinstance(_mysql.get_client_info(), str)) def test_escape_string(self): - self.assertEqual(_mysql.escape_string(b'foo"bar'), - b'foo\\"bar', "escape byte string") - self.assertEqual(_mysql.escape_string(u'foo"bar'), - b'foo\\"bar', "escape unicode string") + self.assertEqual( + _mysql.escape_string(b'foo"bar'), b'foo\\"bar', "escape byte string" + ) + self.assertEqual( + _mysql.escape_string('foo"bar'), b'foo\\"bar', "escape unicode string" + ) class CoreAPI(unittest.TestCase): @@ -53,49 +56,91 @@ def tearDown(self): def test_thread_id(self): tid = self.conn.thread_id() - self.assertTrue(isinstance(tid, int), - "thread_id didn't return an int.") + self.assertTrue(isinstance(tid, int), "thread_id didn't return an int.") - self.assertRaises(TypeError, self.conn.thread_id, ('evil',), - "thread_id shouldn't accept arguments.") + self.assertRaises( + TypeError, + self.conn.thread_id, + ("evil",), + "thread_id shouldn't accept arguments.", + ) def test_affected_rows(self): - self.assertEqual(self.conn.affected_rows(), 0, - "Should return 0 before we do anything.") - + self.assertEqual( + self.conn.affected_rows(), 0, "Should return 0 before we do anything." + ) - #def test_debug(self): - ## FIXME Only actually tests if you lack SUPER - #self.assertRaises(MySQLdb.OperationalError, - #self.conn.dump_debug_info) + # def test_debug(self): + # (FIXME) Only actually tests if you lack SUPER + # self.assertRaises(MySQLdb.OperationalError, + # self.conn.dump_debug_info) def test_charset_name(self): - self.assertTrue(isinstance(self.conn.character_set_name(), str), - "Should return a string.") + self.assertTrue( + isinstance(self.conn.character_set_name(), str), "Should return a string." + ) def test_host_info(self): - self.assertTrue(isinstance(self.conn.get_host_info(), str), - "Should return a string.") + self.assertTrue( + isinstance(self.conn.get_host_info(), str), "Should return a string." + ) def test_proto_info(self): - self.assertTrue(isinstance(self.conn.get_proto_info(), int), - "Should return an int.") + self.assertTrue( + isinstance(self.conn.get_proto_info(), int), "Should return an int." + ) def test_server_info(self): - self.assertTrue(isinstance(self.conn.get_server_info(), str), - "Should return a string.") + self.assertTrue( + isinstance(self.conn.get_server_info(), str), "Should return a string." + ) def test_client_flag(self): conn = connection_factory( - use_unicode=True, - client_flag=MySQLdb.constants.CLIENT.FOUND_ROWS) + use_unicode=True, client_flag=MySQLdb.constants.CLIENT.FOUND_ROWS + ) - self.assertIsInstance(conn.client_flag, (int, MySQLdb.compat.long)) + self.assertIsInstance(conn.client_flag, int) self.assertTrue(conn.client_flag & MySQLdb.constants.CLIENT.FOUND_ROWS) - with self.assertRaises(TypeError if MySQLdb.compat.PY2 else AttributeError): + with self.assertRaises(AttributeError): conn.client_flag = 0 conn.close() def test_fileno(self): self.assertGreaterEqual(self.conn.fileno(), 0) + + 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_MySQLdb_times.py b/tests/test_MySQLdb_times.py index d9d3e027..2081b1ac 100644 --- a/tests/test_MySQLdb_times.py +++ b/tests/test_MySQLdb_times.py @@ -1,109 +1,146 @@ -import mock -import unittest -from time import gmtime from datetime import time, date, datetime, timedelta +from time import gmtime +import unittest +from unittest import mock +import warnings from MySQLdb import times -import warnings + warnings.simplefilter("ignore") class TestX_or_None(unittest.TestCase): def test_date_or_none(self): - assert times.Date_or_None('1969-01-01') == date(1969, 1, 1) - assert times.Date_or_None('2015-01-01') == date(2015, 1, 1) - assert times.Date_or_None('2015-12-13') == date(2015, 12, 13) + assert times.Date_or_None("1969-01-01") == date(1969, 1, 1) + assert times.Date_or_None("2015-01-01") == date(2015, 1, 1) + assert times.Date_or_None("2015-12-13") == date(2015, 12, 13) - assert times.Date_or_None('') is None - assert times.Date_or_None('fail') is None - assert times.Date_or_None('2015-12') is None - assert times.Date_or_None('2015-12-40') is None - assert times.Date_or_None('0000-00-00') is None + assert times.Date_or_None("") is None + assert times.Date_or_None("fail") is None + assert times.Date_or_None("2015-12") is None + assert times.Date_or_None("2015-12-40") is None + assert times.Date_or_None("0000-00-00") is None def test_time_or_none(self): - assert times.Time_or_None('00:00:00') == time(0, 0) - assert times.Time_or_None('01:02:03') == time(1, 2, 3) - assert times.Time_or_None('01:02:03.123456') == time(1, 2, 3, 123456) + assert times.Time_or_None("00:00:00") == time(0, 0) + assert times.Time_or_None("01:02:03") == time(1, 2, 3) + assert times.Time_or_None("01:02:03.123456") == time(1, 2, 3, 123456) - assert times.Time_or_None('') is None - assert times.Time_or_None('fail') is None - assert times.Time_or_None('24:00:00') is None - assert times.Time_or_None('01:02:03.123456789') is None + assert times.Time_or_None("") is None + assert times.Time_or_None("fail") is None + assert times.Time_or_None("24:00:00") is None + assert times.Time_or_None("01:02:03.123456789") is None def test_datetime_or_none(self): - assert times.DateTime_or_None('1000-01-01') == date(1000, 1, 1) - assert times.DateTime_or_None('2015-12-13') == date(2015, 12, 13) - assert times.DateTime_or_None('2015-12-13 01:02') == datetime(2015, 12, 13, 1, 2) - assert times.DateTime_or_None('2015-12-13T01:02') == datetime(2015, 12, 13, 1, 2) - assert times.DateTime_or_None('2015-12-13 01:02:03') == datetime(2015, 12, 13, 1, 2, 3) - assert times.DateTime_or_None('2015-12-13T01:02:03') == datetime(2015, 12, 13, 1, 2, 3) - assert times.DateTime_or_None('2015-12-13 01:02:03.123') == datetime(2015, 12, 13, 1, 2, 3, 123000) - assert times.DateTime_or_None('2015-12-13 01:02:03.000123') == datetime(2015, 12, 13, 1, 2, 3, 123) - assert times.DateTime_or_None('2015-12-13 01:02:03.123456') == datetime(2015, 12, 13, 1, 2, 3, 123456) - assert times.DateTime_or_None('2015-12-13T01:02:03.123456') == datetime(2015, 12, 13, 1, 2, 3, 123456) - - assert times.DateTime_or_None('') is None - assert times.DateTime_or_None('fail') is None - assert times.DateTime_or_None('0000-00-00 00:00:00') is None - assert times.DateTime_or_None('0000-00-00 00:00:00.000000') is None - assert times.DateTime_or_None('2015-12-13T01:02:03.123456789') is None + assert times.DateTime_or_None("1000-01-01") == date(1000, 1, 1) + assert times.DateTime_or_None("2015-12-13") == date(2015, 12, 13) + assert times.DateTime_or_None("2015-12-13 01:02") == datetime( + 2015, 12, 13, 1, 2 + ) + assert times.DateTime_or_None("2015-12-13T01:02") == datetime( + 2015, 12, 13, 1, 2 + ) + assert times.DateTime_or_None("2015-12-13 01:02:03") == datetime( + 2015, 12, 13, 1, 2, 3 + ) + assert times.DateTime_or_None("2015-12-13T01:02:03") == datetime( + 2015, 12, 13, 1, 2, 3 + ) + assert times.DateTime_or_None("2015-12-13 01:02:03.123") == datetime( + 2015, 12, 13, 1, 2, 3, 123000 + ) + assert times.DateTime_or_None("2015-12-13 01:02:03.000123") == datetime( + 2015, 12, 13, 1, 2, 3, 123 + ) + assert times.DateTime_or_None("2015-12-13 01:02:03.123456") == datetime( + 2015, 12, 13, 1, 2, 3, 123456 + ) + assert times.DateTime_or_None("2015-12-13T01:02:03.123456") == datetime( + 2015, 12, 13, 1, 2, 3, 123456 + ) + + assert times.DateTime_or_None("") is None + assert times.DateTime_or_None("fail") is None + assert times.DateTime_or_None("0000-00-00 00:00:00") is None + assert times.DateTime_or_None("0000-00-00 00:00:00.000000") is None + assert times.DateTime_or_None("2015-12-13T01:02:03.123456789") is None def test_timedelta_or_none(self): - assert times.TimeDelta_or_None('-1:0:0') == timedelta(0, -3600) - assert times.TimeDelta_or_None('1:0:0') == timedelta(0, 3600) - assert times.TimeDelta_or_None('12:55:30') == timedelta(0, 46530) - assert times.TimeDelta_or_None('12:55:30.123456') == timedelta(0, 46530, 123456) - assert times.TimeDelta_or_None('12:55:30.123456789') == timedelta(0, 46653, 456789) - assert times.TimeDelta_or_None('12:55:30.123456789123456') == timedelta(1429, 37719, 123456) - - assert times.TimeDelta_or_None('') is None - assert times.TimeDelta_or_None('0') is None - assert times.TimeDelta_or_None('fail') is None + assert times.TimeDelta_or_None("-1:0:0") == timedelta(0, -3600) + assert times.TimeDelta_or_None("1:0:0") == timedelta(0, 3600) + assert times.TimeDelta_or_None("12:55:30") == timedelta(0, 46530) + assert times.TimeDelta_or_None("12:55:30.123456") == timedelta(0, 46530, 123456) + assert times.TimeDelta_or_None("12:55:30.123456789") == timedelta( + 0, 46653, 456789 + ) + assert times.TimeDelta_or_None("12:55:30.123456789123456") == timedelta( + 1429, 37719, 123456 + ) + + assert times.TimeDelta_or_None("") is None + assert times.TimeDelta_or_None("0") is None + assert times.TimeDelta_or_None("fail") is None class TestTicks(unittest.TestCase): - @mock.patch('MySQLdb.times.localtime', side_effect=gmtime) + @mock.patch("MySQLdb.times.localtime", side_effect=gmtime) def test_date_from_ticks(self, mock): assert times.DateFromTicks(0) == date(1970, 1, 1) assert times.DateFromTicks(1430000000) == date(2015, 4, 25) - @mock.patch('MySQLdb.times.localtime', side_effect=gmtime) + @mock.patch("MySQLdb.times.localtime", side_effect=gmtime) def test_time_from_ticks(self, mock): assert times.TimeFromTicks(0) == time(0, 0, 0) assert times.TimeFromTicks(1431100000) == time(15, 46, 40) assert times.TimeFromTicks(1431100000.123) == time(15, 46, 40) - @mock.patch('MySQLdb.times.localtime', side_effect=gmtime) + @mock.patch("MySQLdb.times.localtime", side_effect=gmtime) def test_timestamp_from_ticks(self, mock): assert times.TimestampFromTicks(0) == datetime(1970, 1, 1, 0, 0, 0) assert times.TimestampFromTicks(1430000000) == datetime(2015, 4, 25, 22, 13, 20) - assert times.TimestampFromTicks(1430000000.123) == datetime(2015, 4, 25, 22, 13, 20) + assert times.TimestampFromTicks(1430000000.123) == datetime( + 2015, 4, 25, 22, 13, 20 + ) class TestToLiteral(unittest.TestCase): def test_datetime_to_literal(self): - assert times.DateTime2literal(datetime(2015, 12, 13), '') == b"'2015-12-13 00:00:00'" - assert times.DateTime2literal(datetime(2015, 12, 13, 11, 12, 13), '') == b"'2015-12-13 11:12:13'" - assert times.DateTime2literal(datetime(2015, 12, 13, 11, 12, 13, 123456), '') == b"'2015-12-13 11:12:13.123456'" + self.assertEqual( + times.DateTime2literal(datetime(2015, 12, 13), ""), b"'2015-12-13 00:00:00'" + ) + self.assertEqual( + times.DateTime2literal(datetime(2015, 12, 13, 11, 12, 13), ""), + b"'2015-12-13 11:12:13'", + ) + self.assertEqual( + times.DateTime2literal(datetime(2015, 12, 13, 11, 12, 13, 123456), ""), + b"'2015-12-13 11:12:13.123456'", + ) def test_datetimedelta_to_literal(self): d = datetime(2015, 12, 13, 1, 2, 3) - datetime(2015, 12, 13, 1, 2, 2) - assert times.DateTimeDelta2literal(d, '') == b"'0 0:0:1'" + assert times.DateTimeDelta2literal(d, "") == b"'0 0:0:1'" class TestFormat(unittest.TestCase): def test_format_timedelta(self): d = datetime(2015, 1, 1) - datetime(2015, 1, 1) - assert times.format_TIMEDELTA(d) == '0 0:0:0' + assert times.format_TIMEDELTA(d) == "0 0:0:0" d = datetime(2015, 1, 1, 10, 11, 12) - datetime(2015, 1, 1, 8, 9, 10) - assert times.format_TIMEDELTA(d) == '0 2:2:2' + assert times.format_TIMEDELTA(d) == "0 2:2:2" d = datetime(2015, 1, 1, 10, 11, 12) - datetime(2015, 1, 1, 11, 12, 13) - assert times.format_TIMEDELTA(d) == '-1 22:58:59' + assert times.format_TIMEDELTA(d) == "-1 22:58:59" def test_format_timestamp(self): - assert times.format_TIMESTAMP(datetime(2015, 2, 3)) == '2015-02-03 00:00:00' - assert times.format_TIMESTAMP(datetime(2015, 2, 3, 17, 18, 19)) == '2015-02-03 17:18:19' - assert times.format_TIMESTAMP(datetime(15, 2, 3, 17, 18, 19)) == '0015-02-03 17:18:19' + assert times.format_TIMESTAMP(datetime(2015, 2, 3)) == "2015-02-03 00:00:00" + self.assertEqual( + times.format_TIMESTAMP(datetime(2015, 2, 3, 17, 18, 19)), + "2015-02-03 17:18:19", + ) + self.assertEqual( + times.format_TIMESTAMP(datetime(15, 2, 3, 17, 18, 19)), + "0015-02-03 17:18:19", + ) diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 00000000..960de572 --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,26 @@ +import pytest + +from MySQLdb._exceptions import ProgrammingError + +from configdb import connection_factory + + +def test_multi_statements_default_true(): + conn = connection_factory() + cursor = conn.cursor() + + cursor.execute("select 17; select 2") + rows = cursor.fetchall() + assert rows == ((17,),) + + +def test_multi_statements_false(): + conn = connection_factory(multi_statements=False) + cursor = conn.cursor() + + with pytest.raises(ProgrammingError): + cursor.execute("select 17; select 2") + + cursor.execute("select 17") + rows = cursor.fetchall() + assert rows == ((17,),) diff --git a/tests/test_cursor.py b/tests/test_cursor.py index ff96368f..1d2c3655 100644 --- a/tests/test_cursor.py +++ b/tests/test_cursor.py @@ -1,5 +1,3 @@ -from __future__ import print_function, absolute_import - import pytest import MySQLdb.cursors from configdb import connection_factory @@ -8,6 +6,7 @@ _conns = [] _tables = [] + def connect(**kwargs): conn = connection_factory(**kwargs) _conns.append(conn) @@ -19,7 +18,7 @@ def teardown_function(function): c = _conns[0] cur = c.cursor() for t in _tables: - cur.execute("DROP TABLE %s" % (t,)) + cur.execute(f"DROP TABLE {t}") cur.close() del _tables[:] @@ -35,47 +34,71 @@ def test_executemany(): cursor.execute("create table test (data varchar(10))") _tables.append("test") - m = MySQLdb.cursors.RE_INSERT_VALUES.match("INSERT INTO TEST (ID, NAME) VALUES (%s, %s)") - assert m is not None, 'error parse %s' - assert m.group(3) == '', 'group 3 not blank, bug in RE_INSERT_VALUES?' - - m = MySQLdb.cursors.RE_INSERT_VALUES.match("INSERT INTO TEST (ID, NAME) VALUES (%(id)s, %(name)s)") - assert m is not None, 'error parse %(name)s' - assert m.group(3) == '', 'group 3 not blank, bug in RE_INSERT_VALUES?' - - m = MySQLdb.cursors.RE_INSERT_VALUES.match("INSERT INTO TEST (ID, NAME) VALUES (%(id_name)s, %(name)s)") - assert m is not None, 'error parse %(id_name)s' - assert m.group(3) == '', 'group 3 not blank, bug in RE_INSERT_VALUES?' - - m = MySQLdb.cursors.RE_INSERT_VALUES.match("INSERT INTO TEST (ID, NAME) VALUES (%(id_name)s, %(name)s) ON duplicate update") - assert m is not None, 'error parse %(id_name)s' - assert m.group(3) == ' ON duplicate update', 'group 3 not ON duplicate update, bug in RE_INSERT_VALUES?' + m = MySQLdb.cursors.RE_INSERT_VALUES.match( + "INSERT INTO TEST (ID, NAME) VALUES (%s, %s)" + ) + assert m is not None, "error parse %s" + assert m.group(3) == "", "group 3 not blank, bug in RE_INSERT_VALUES?" + + m = MySQLdb.cursors.RE_INSERT_VALUES.match( + "INSERT INTO TEST (ID, NAME) VALUES (%(id)s, %(name)s)" + ) + assert m is not None, "error parse %(name)s" + assert m.group(3) == "", "group 3 not blank, bug in RE_INSERT_VALUES?" + + m = MySQLdb.cursors.RE_INSERT_VALUES.match( + "INSERT INTO TEST (ID, NAME) VALUES (%(id_name)s, %(name)s)" + ) + assert m is not None, "error parse %(id_name)s" + assert m.group(3) == "", "group 3 not blank, bug in RE_INSERT_VALUES?" + + m = MySQLdb.cursors.RE_INSERT_VALUES.match( + "INSERT INTO TEST (ID, NAME) VALUES (%(id_name)s, %(name)s) ON duplicate update" + ) + assert m is not None, "error parse %(id_name)s" + assert ( + m.group(3) == " ON duplicate update" + ), "group 3 not ON duplicate update, bug in RE_INSERT_VALUES?" # https://github.com/PyMySQL/mysqlclient-python/issues/178 - m = MySQLdb.cursors.RE_INSERT_VALUES.match("INSERT INTO bloup(foo, bar)VALUES(%s, %s)") + m = MySQLdb.cursors.RE_INSERT_VALUES.match( + "INSERT INTO bloup(foo, bar)VALUES(%s, %s)" + ) assert m is not None - # cursor._executed myst bee "insert into test (data) values (0),(1),(2),(3),(4),(5),(6),(7),(8),(9)" + # cursor._executed myst bee + # """ + # insert into test (data) + # 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)"), 'execute many with %s not in one query' + assert cursor._executed.endswith( + b",(7),(8),(9)" + ), "execute many with %s not in one query" # dict args - data_dict = [{'data': i} for i in range(10)] + data_dict = [{"data": i} for i in range(10)] cursor.executemany("insert into test (data) values (%(data)s)", data_dict) - assert cursor._executed.endswith(b",(7),(8),(9)"), 'execute many with %(data)s not in one query' + assert cursor._executed.endswith( + b",(7),(8),(9)" + ), "execute many with %(data)s not in one query" # %% in column set - cursor.execute("""\ + cursor.execute( + """\ CREATE TABLE percent_test ( `A%` INTEGER, - `B%` INTEGER)""") + `B%` INTEGER)""" + ) try: q = "INSERT INTO percent_test (`A%%`, `B%%`) VALUES (%s, %s)" assert MySQLdb.cursors.RE_INSERT_VALUES.match(q) is not None cursor.executemany(q, [(3, 4), (5, 6)]) - assert cursor._executed.endswith(b"(3, 4),(5, 6)"), "executemany with %% not in one query" + assert cursor._executed.endswith( + b"(3, 4),(5, 6)" + ), "executemany with %% not in one query" finally: cursor.execute("DROP TABLE IF EXISTS percent_test") @@ -84,7 +107,139 @@ def test_pyparam(): conn = connect() cursor = conn.cursor() - cursor.execute(u"SELECT %(a)s, %(b)s", {u'a': 1, u'b': 2}) + cursor.execute("SELECT %(a)s, %(b)s", {"a": 1, "b": 2}) assert cursor._executed == b"SELECT 1, 2" - cursor.execute(b"SELECT %(a)s, %(b)s", {b'a': 3, b'b': 4}) + cursor.execute(b"SELECT %(a)s, %(b)s", {b"a": 3, b"b": 4}) assert cursor._executed == b"SELECT 3, 4" + + +def test_dictcursor(): + conn = connect() + cursor = conn.cursor(MySQLdb.cursors.DictCursor) + + cursor.execute("CREATE TABLE t1 (a int, b int, c int)") + _tables.append("t1") + cursor.execute("INSERT INTO t1 (a,b,c) VALUES (1,1,47), (2,2,47)") + + cursor.execute("CREATE TABLE t2 (b int, c int)") + _tables.append("t2") + cursor.execute("INSERT INTO t2 (b,c) VALUES (1,1), (2,2)") + + cursor.execute("SELECT * FROM t1 JOIN t2 ON t1.b=t2.b") + rows = cursor.fetchall() + + assert len(rows) == 2 + assert rows[0] == {"a": 1, "b": 1, "c": 47, "t2.b": 1, "t2.c": 1} + assert rows[1] == {"a": 2, "b": 2, "c": 47, "t2.b": 2, "t2.c": 2} + + names1 = sorted(rows[0]) + names2 = sorted(rows[1]) + for a, b in zip(names1, names2): + assert a is b + + # Old fetchtype + cursor._fetch_type = 2 + cursor.execute("SELECT * FROM t1 JOIN t2 ON t1.b=t2.b") + rows = cursor.fetchall() + + assert len(rows) == 2 + assert rows[0] == {"t1.a": 1, "t1.b": 1, "t1.c": 47, "t2.b": 1, "t2.c": 1} + assert rows[1] == {"t1.a": 2, "t1.b": 2, "t1.c": 47, "t2.b": 2, "t2.c": 2} + + names1 = sorted(rows[0]) + 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)") diff --git a/tests/travis.cnf b/tests/travis.cnf index 05ff8039..5fd6f847 100644 --- a/tests/travis.cnf +++ b/tests/travis.cnf @@ -7,5 +7,4 @@ host = 127.0.0.1 port = 3306 user = root database = mysqldb_test -#password = travis default-character-set = utf8mb4