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