From 861539c240e244cac7b4de8401dcffcf7d49b861 Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 12 Dec 2022 09:07:28 +0200 Subject: [PATCH] Modernizing: Restoring CI, Moving to pytest (#136) closes https://github.com/redis/hiredis-py/pull/120 closes https://github.com/redis/hiredis-py/pull/122 closes https://github.com/redis/hiredis-py/pull/124 closes https://github.com/redis/hiredis-py/pull/129 closes https://github.com/redis/hiredis-py/issues/130 closes https://github.com/redis/hiredis-py/issues/121 closes https://github.com/redis/hiredis-py/issues/118 --- .github/release-drafter-config.yml | 12 +- .github/workflows/installtest.sh | 30 +++ .github/workflows/integration.yaml | 51 ++++ .github/workflows/pypi-publish.yaml | 78 ++++++ .github/workflows/release-drafter.yml | 4 + .gitignore | 2 + .travis.yml | 102 -------- README.md | 19 +- appveyor.yml | 101 -------- dev_requirements.txt | 7 + setup.py | 3 +- src/reader.c | 7 +- test.py | 9 - test/__init__.py | 12 - test/reader.py | 320 ------------------------- tests/__init__.py | 0 tests/test_reader.py | 329 ++++++++++++++++++++++++++ tox.ini | 41 ++++ 18 files changed, 570 insertions(+), 557 deletions(-) create mode 100755 .github/workflows/installtest.sh create mode 100644 .github/workflows/integration.yaml create mode 100644 .github/workflows/pypi-publish.yaml delete mode 100644 .travis.yml delete mode 100644 appveyor.yml create mode 100644 dev_requirements.txt delete mode 100755 test.py delete mode 100644 test/__init__.py delete mode 100644 test/reader.py create mode 100644 tests/__init__.py create mode 100644 tests/test_reader.py create mode 100644 tox.ini diff --git a/.github/release-drafter-config.yml b/.github/release-drafter-config.yml index aab645f..9ccb28a 100644 --- a/.github/release-drafter-config.yml +++ b/.github/release-drafter-config.yml @@ -1,5 +1,5 @@ -name-template: 'Version $NEXT_PATCH_VERSION' -tag-template: 'v$NEXT_PATCH_VERSION' +name-template: '$NEXT_MINOR_VERSION' +tag-template: 'v$NEXT_MINOR_VERSION' autolabeler: - label: 'maintenance' files: @@ -15,9 +15,12 @@ autolabeler: branch: - '/feature-.+' categories: - - title: '๐Ÿ”ฅ Breaking Changes' + - title: 'Breaking Changes' labels: - 'breakingchange' + - title: '๐Ÿงช Experimental Features' + labels: + - 'experimental' - title: '๐Ÿš€ New Features' labels: - 'feature' @@ -27,13 +30,14 @@ categories: - 'fix' - 'bugfix' - 'bug' + - 'BUG' - title: '๐Ÿงฐ Maintenance' label: 'maintenance' change-template: '- $TITLE (#$NUMBER)' exclude-labels: - 'skip-changelog' template: | - ## Changes + # Changes $CHANGES diff --git a/.github/workflows/installtest.sh b/.github/workflows/installtest.sh new file mode 100755 index 0000000..209b6b7 --- /dev/null +++ b/.github/workflows/installtest.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e + +SUFFIX=$1 +if [ -z ${SUFFIX} ]; then + echo "Supply valid python package extension such as whl or tar.gz. Exiting." + exit 3 +fi + +script=`pwd`/${BASH_SOURCE[0]} +HERE=`dirname ${script}` +ROOT=`realpath ${HERE}/../..` + +cd ${ROOT} +DESTENV=${ROOT}/.venvforinstall +if [ -d ${DESTENV} ]; then + rm -rf ${DESTENV} +fi +python -m venv ${DESTENV} +source ${DESTENV}/bin/activate +pip install --upgrade --quiet pip +pip install --quiet -r dev_requirements.txt +python setup.py sdist bdist_wheel + +PKG=`ls ${ROOT}/dist/*.${SUFFIX}` +ls -l ${PKG} + +# install, run tests +pip install ${PKG} \ No newline at end of file diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml new file mode 100644 index 0000000..eaf9bff --- /dev/null +++ b/.github/workflows/integration.yaml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + paths-ignore: + - 'docs/**' + - '**/*.rst' + - '**/*.md' + branches: + - master + - '[0-9].[0-9]' + pull_request: + branches: + - master + - '[0-9].[0-9]' + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + + run-tests: + runs-on: ${{matrix.os}} + timeout-minutes: 30 + strategy: + max-parallel: 15 + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.7', 'pypy-3.8'] + os: ['ubuntu-latest', 'windows-latest', 'macos-latest'] + fail-fast: false + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: true + name: Python ${{ matrix.python-version }} ${{matrix.os}} + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: dev_requirements.txt + - name: run tests + run: | + pip install -U pip setuptools wheel + pip install -r dev_requirements.txt + python setup.py build_ext --inplace + pytest + - name: build and install the wheel + run: | + python setup.py bdist_wheel \ No newline at end of file diff --git a/.github/workflows/pypi-publish.yaml b/.github/workflows/pypi-publish.yaml new file mode 100644 index 0000000..237d58f --- /dev/null +++ b/.github/workflows/pypi-publish.yaml @@ -0,0 +1,78 @@ +name: Publish tag to Pypi + +on: + release: + types: [published] + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + + build_wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-20.04, windows-2019, macos-10.15] + env: + CIBW_ARCHS_MACOS: "x86_64 universal2 arm64" + MACOSX_DEPLOYMENT_TARGET: "10.12" + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Set up QEMU + if: runner.os == 'Linux' + uses: docker/setup-qemu-action@v2 + with: + platforms: all + + - name: Build wheels + uses: pypa/cibuildwheel@v2.11.2 + env: + # configure cibuildwheel to build native archs ('auto'), and some + # emulated ones + CIBW_ARCHS_LINUX: auto aarch64 ppc64le s390x + + - uses: actions/upload-artifact@v3 + with: + name: ${{matrix.os}}-wheels + path: ./wheelhouse/*.whl + + publish: + name: Pypi publish + needs: ['build_wheels'] + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v4 + with: + python-version: 3.9 + - name: Install tools + run: | + pip install twine wheel + - uses: actions/download-artifact@v3 + with: + name: ubuntu-20.04-wheels + path: artifacts/linux + - uses: actions/download-artifact@v3 + with: + name: windows-2019-wheels + path: artifacts/windows + - uses: actions/download-artifact@v3 + with: + name: macos-10.15-wheels + path: artifacts/macos + - name: unify wheel structure + run: | + mkdir dist + cp -R artifacts/windows/* dist + cp -R artifacts/linux/* dist + cp -R artifacts/macos/* dist + + - name: Publish to Pypi + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index ec2d88b..6055335 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -8,6 +8,10 @@ on: jobs: update_release_draft: + permissions: + pull-requests: write # to add label to PR (release-drafter/release-drafter) + contents: write # to create a github release (release-drafter/release-drafter) + runs-on: ubuntu-latest steps: # Drafts your next Release notes as Pull Requests are merged into "master" diff --git a/.gitignore b/.gitignore index ec5dd74..dbe349b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /build /dist MANIFEST +.venv +**/*.so diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c9e82ef..0000000 --- a/.travis.yml +++ /dev/null @@ -1,102 +0,0 @@ -sudo: false -language: python -dist: xenial - - -matrix: - include: - - python: "3.6" - - python: "3.6" - arch: arm64 - - python: "3.7" - - python: "3.7" - arch: arm64 - - python: "3.8" - - python: "3.8" - arch: arm64 - - python: "3.9" - - python: "3.9" - arch: arm64 - - python: "3.10" - - python: "3.10" - arch: arm64 - # linux wheels - - sudo: required - services: - - docker - env: HIREDIS_PY_BUILDWHEELS=1 - # linux aarch64 (arm64) wheels - - arch: arm64 - sudo: required - services: - - docker - env: HIREDIS_PY_BUILDWHEELS=1 - # osx wheels - - os: osx - osx_image: xcode9.4 - language: generic - env: HIREDIS_PY_BUILDWHEELS=1 - python: "3.6" - before_install: - - SSL_CERT_FILE=$(brew --prefix)/etc/openssl/cert.pem - - sudo pip install -U pip setuptools twine - -branches: - only: - - staging - - trying - - master - - /^v.*$/ - - py38 - -install: - - | - if [ -n "${HIREDIS_PY_BUILDWHEELS:-}" ]; then - pip3 install cibuildwheel - else - python3 setup.py build_ext --inplace - fi - -script: - - | - if [ -n "${HIREDIS_PY_BUILDWHEELS:-}" ]; then - cibuildwheel --output-dir dist - else - python3 test.py - fi - -before_deploy: - - ls dist/ - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then - python3 -m venv ~/venv; - source ~/venv/bin/activate; - fi - -deploy: - # deploy master non-tags to Test PyPI - - provider: pypi - user: ifduyue - password: - secure: "Hn8n7k11TQQF2PWbx8aYhVf6j5bVh8s9/5HuA0eEW4Vl3TmBzyrh2OPKPgrlh9WLvkBUyl0SJzvqxh+SOKP3dg6XOItvZzm/ZnN77gVbrMkjpNOmfENb6Amdx7y1uDG60UFd5H35D8SoinGmW9QSyxMjB7eIH+qybGUXoSV4BaM=" - server: https://test.pypi.org/legacy/ - distributions: sdist - skip_existing: true - skip_cleanup: true - on: - repo: redis/hiredis-py - tags: false - branch: master - condition: -n "${HIREDIS_PY_BUILDWHEELS:-}" - - # deploy tags to PyPI - - provider: pypi - user: ifduyue - password: - secure: "WgO8677gsCeftEIdozL5albCmXuVwuyHLZur6mP1cvEGDGdzatDCwZJkM1pdOCy4xXYYz3+bMsya5gLbQmGZOYBzieAb4CYR+O38Kd0mVCoZpK7TYmN55G+Tn3bztxFOBtInqd9bf1JkPE5eXN7Lc4rkMhMmafxoN8aBVPlfhRM=" - distributions: sdist - skip_existing: true - skip_cleanup: true - on: - repo: redis/hiredis-py - tags: true - condition: -n "${HIREDIS_PY_BUILDWHEELS:-}" diff --git a/README.md b/README.md index c09a2c2..0d6cbf4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # hiredis-py -[![Build Status](https://travis-ci.org/redis/hiredis-py.svg?branch=master)](https://travis-ci.org/redis/hiredis-py) -[![Windows Build Status](https://ci.appveyor.com/api/projects/status/muso9gbe316tjsac/branch/master?svg=true)](https://ci.appveyor.com/project/duyue/hiredis-py/) +[![Build Status](https://github.com/redis/hiredis-py/actions/workflows/integration.yaml/badge.svg)](https://github.com/redis/hiredis-py/actions/workflows/integration.yaml) [![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) Python extension that wraps protocol parsing code in [hiredis][hiredis]. @@ -11,16 +10,24 @@ It primarily speeds up parsing of multi bulk replies. ## Install -hiredis-py is available on [PyPI](https://pypi.org/project/hiredis/), and can -be installed with: +hiredis-py is available on [PyPI](https://pypi.org/project/hiredis/), and can be installed via: -``` +```bash pip install hiredis ``` +## Building and Testing + +Building this repository requires a recursive checkout of submodules, and building hiredis. The following example shows how to clone, compile, and run tests. Please note - you will need the gcc installed. + +```bash +git clone --recursse-submodules https://github.com/redis/hiredis-py +python setup.py build_ext --inplace +pytest +``` ### Requirements -hiredis-py requires **Python 3.6+**. +hiredis-py requires **Python 3.7+**. Make sure Python development headers are available when installing hiredis-py. On Ubuntu/Debian systems, install them with `apt-get install python3-dev`. diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 28d467b..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,101 +0,0 @@ -# https://github.com/ogrisel/python-appveyor-demo/blob/master/appveyor.yml -environment: - global: - PYPI_USER: ifduyue - PYPI_TEST_PASSWORD: - secure: Ub5TGKonq/xFgzRLFMCcKQ== - PYPI_PASSWORD: - secure: fFfFN5N5920gtX3+pwrOddk/psDk3wK67snCOt209bc= - - matrix: - - PYTHON: "C:\\Python36" - PYTHON_VERSION: "3.6.6" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python36-x64" - PYTHON_VERSION: "3.6.6" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python37" - PYTHON_VERSION: "3.7.0" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python37-x64" - PYTHON_VERSION: "3.7.0" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python38" - PYTHON_VERSION: "3.8.0" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python38-x64" - PYTHON_VERSION: "3.8.0" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python39" - PYTHON_VERSION: "3.9.1" - PYTHON_ARCH: "32" - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 - - - PYTHON: "C:\\Python39-x64" - PYTHON_VERSION: "3.9.1" - PYTHON_ARCH: "64" - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 - - - PYTHON: "C:\\Python310" - PYTHON_VERSION: "3.10.0" - PYTHON_ARCH: "32" - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 - - - PYTHON: "C:\\Python310-x64" - PYTHON_VERSION: "3.10.0" - PYTHON_ARCH: "64" - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 - - # build wheels - - PYTHON: "C:\\Python38-x64" - PYTHON_VERSION: "3.8.0" - PYTHON_ARCH: "64" - HIREDIS_PY_BUILDWHEELS: 1 - - -install: - - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" - - "python --version" - - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" - - "git submodule update --init --recursive" - -build: off - -test_script: - - "python setup.py build_ext --inplace" - - "python test.py" - - ps: | - if (Test-Path env:HIREDIS_PY_BUILDWHEELS) { - python -m pip install -U pip setuptools cibuildwheel - cibuildwheel --output-dir wheels - ls wheels - } - -artifacts: - - path: wheels\*.whl - name: Wheels - -on_success: - # deploy master non-tags to Test PyPI - - ps: | - if (!(Test-Path env:HIREDIS_PY_BUILDWHEELS)) { return } - if (Test-Path env:APPVEYOR_PULL_REQUEST_NUMBER) { return } - if ($env:APPVEYOR_REPO_NAME -ne 'redis/hiredis-py') { return } - if ($env:APPVEYOR_REPO_BRANCH -ne 'master') { return } - pip install -U twine - twine upload -u $env:PYPI_USER -p $env:PYPI_TEST_PASSWORD --repository-url https://test.pypi.org/legacy/ --skip-existing wheels\*.whl - - # deploy tags to PyPI - - ps: | - if (!(Test-Path env:HIREDIS_PY_BUILDWHEELS)) { return } - if (Test-Path env:APPVEYOR_PULL_REQUEST_NUMBER) { return } - if ($env:APPVEYOR_REPO_NAME -ne 'redis/hiredis-py') { return } - if ($env:APPVEYOR_REPO_TAG -ne 'true') { return } - pip install -U twine - twine upload -u $env:PYPI_USER -p $env:PYPI_PASSWORD --skip-existing wheels\*.whl diff --git a/dev_requirements.txt b/dev_requirements.txt new file mode 100644 index 0000000..3ff7078 --- /dev/null +++ b/dev_requirements.txt @@ -0,0 +1,7 @@ +black==22.3.0 +flake8==4.0.1 +isort==5.10.1 +tox==3.24.4 +vulture>=2.3.0 +wheel>=0.30.0 +pytest>=7.0.0 diff --git a/setup.py b/setup.py index c3d9a1e..1b4b9d7 100755 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def version(): packages=["hiredis"], package_data={"hiredis": ["hiredis.pyi", "py.typed"]}, ext_modules=[ext], - python_requires=">=3.6", + python_requires=">=3.7", project_urls={ "Changes": "https://github.com/redis/hiredis-py/releases", "Issue tracker": "https://github.com/hiredis/redis-py/issues", @@ -45,7 +45,6 @@ def version(): 'Programming Language :: C', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', diff --git a/src/reader.c b/src/reader.c index bf99b58..e194202 100644 --- a/src/reader.c +++ b/src/reader.c @@ -288,9 +288,13 @@ static int Reader_init(hiredis_ReaderObject *self, PyObject *args, PyObject *kwd if (!_Reader_set_exception(&self->replyErrorClass, replyErrorClass)) return -1; - if (notEnoughData) + if (notEnoughData) { + Py_DECREF(self->notEnoughDataObject); self->notEnoughDataObject = notEnoughData; + Py_INCREF(self->notEnoughDataObject); + } + return _Reader_set_encoding(self, encoding, errors); } @@ -375,6 +379,7 @@ static PyObject *Reader_gets(hiredis_ReaderObject *self, PyObject *args) { } if (obj == NULL) { + Py_INCREF(self->notEnoughDataObject); return self->notEnoughDataObject; } else { /* Restore error when there is one. */ diff --git a/test.py b/test.py deleted file mode 100755 index 5a77113..0000000 --- a/test.py +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env python - -from unittest import TextTestRunner -import test -import sys - -result = TextTestRunner().run(test.tests()) -if not result.wasSuccessful(): - sys.exit(1) diff --git a/test/__init__.py b/test/__init__.py deleted file mode 100644 index da29d6b..0000000 --- a/test/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -import glob, os.path, sys - -version = sys.version.split(" ")[0] -majorminor = version[0:3] - -from unittest import * -from . import reader - -def tests(): - suite = TestSuite() - suite.addTest(makeSuite(reader.ReaderTest)) - return suite diff --git a/test/reader.py b/test/reader.py deleted file mode 100644 index 0561939..0000000 --- a/test/reader.py +++ /dev/null @@ -1,320 +0,0 @@ -# coding=utf-8 -from unittest import * -import hiredis - - -class ReaderTest(TestCase): - def setUp(self): - self.reader = hiredis.Reader() - - def reply(self): - return self.reader.gets() - - def test_nothing(self): - self.assertEquals(False, self.reply()) - - def test_error_when_feeding_non_string(self): - self.assertRaises(TypeError, self.reader.feed, 1) - - def test_protocol_error(self): - self.reader.feed(b"x") - self.assertRaises(hiredis.ProtocolError, self.reply) - - def test_protocol_error_with_custom_class(self): - self.reader = hiredis.Reader(protocolError=RuntimeError) - self.reader.feed(b"x") - self.assertRaises(RuntimeError, self.reply) - - def test_protocol_error_with_custom_callable(self): - class CustomException(Exception): - pass - - self.reader = hiredis.Reader(protocolError=lambda e: CustomException(e)) - self.reader.feed(b"x") - self.assertRaises(CustomException, self.reply) - - def test_fail_with_wrong_protocol_error_class(self): - self.assertRaises(TypeError, hiredis.Reader, protocolError="wrong") - - def test_faulty_protocol_error_class(self): - def make_error(errstr): - 1 / 0 - self.reader = hiredis.Reader(protocolError=make_error) - self.reader.feed(b"x") - self.assertRaises(ZeroDivisionError, self.reply) - - def test_error_string(self): - self.reader.feed(b"-error\r\n") - error = self.reply() - - self.assertEquals(hiredis.ReplyError, type(error)) - self.assertEquals(("error",), error.args) - - def test_error_string_with_custom_class(self): - self.reader = hiredis.Reader(replyError=RuntimeError) - self.reader.feed(b"-error\r\n") - error = self.reply() - - self.assertEquals(RuntimeError, type(error)) - self.assertEquals(("error",), error.args) - - def test_error_string_with_custom_callable(self): - class CustomException(Exception): - pass - - self.reader = hiredis.Reader(replyError=lambda e: CustomException(e)) - self.reader.feed(b"-error\r\n") - error = self.reply() - - self.assertEquals(CustomException, type(error)) - self.assertEquals(("error",), error.args) - - def test_error_string_with_non_utf8_chars(self): - self.reader.feed(b"-error \xd1\r\n") - error = self.reply() - - expected = "error \ufffd" - - self.assertEquals(hiredis.ReplyError, type(error)) - self.assertEquals((expected,), error.args) - - def test_fail_with_wrong_reply_error_class(self): - self.assertRaises(TypeError, hiredis.Reader, replyError="wrong") - - def test_faulty_reply_error_class(self): - def make_error(errstr): - 1 / 0 - - self.reader = hiredis.Reader(replyError=make_error) - self.reader.feed(b"-error\r\n") - self.assertRaises(ZeroDivisionError, self.reply) - - def test_errors_in_nested_multi_bulk(self): - self.reader.feed(b"*2\r\n-err0\r\n-err1\r\n") - - for r, error in zip(("err0", "err1"), self.reply()): - self.assertEquals(hiredis.ReplyError, type(error)) - self.assertEquals((r,), error.args) - - def test_errors_with_non_utf8_chars_in_nested_multi_bulk(self): - self.reader.feed(b"*2\r\n-err\xd1\r\n-err1\r\n") - - expected = "err\ufffd" - - for r, error in zip((expected, "err1"), self.reply()): - self.assertEquals(hiredis.ReplyError, type(error)) - self.assertEquals((r,), error.args) - - def test_integer(self): - value = 2**63-1 # Largest 64-bit signed integer - self.reader.feed((":%d\r\n" % value).encode("ascii")) - self.assertEquals(value, self.reply()) - - def test_float(self): - value = -99.99 - self.reader.feed(b",%f\r\n" % value) - self.assertEqual(value, self.reply()) - - def test_boolean_true(self): - self.reader.feed(b"#t\r\n") - self.assertTrue(self.reply()) - - def test_boolean_false(self): - self.reader.feed(b"#f\r\n") - self.assertFalse(False, self.reply()) - - def test_none(self): - self.reader.feed(b"_\r\n") - self.assertIsNone(self.reply()) - - def test_set(self): - self.reader.feed(b"~3\r\n+tangerine\r\n_\r\n,10.5\r\n") - self.assertEqual({b"tangerine", None, 10.5}, self.reply()) - - def test_dict(self): - self.reader.feed(b"%2\r\n+radius\r\n,4.5\r\n+diameter\r\n:9\r\n") - self.assertEqual({b"radius": 4.5, b"diameter": 9}, self.reply()) - - def test_vector(self): - self.reader.feed(b">4\r\n+pubsub\r\n+message\r\n+channel\r\n+message\r\n") - self.assertEqual( - [b"pubsub", b"message", b"channel", b"message"], self.reply() - ) - - def test_verbatim_string(self): - value = b"text" - self.reader.feed(b"=8\r\ntxt:%s\r\n" % value) - self.assertEqual(value, self.reply()) - - def test_status_string(self): - self.reader.feed(b"+ok\r\n") - self.assertEquals(b"ok", self.reply()) - - def test_empty_bulk_string(self): - self.reader.feed(b"$0\r\n\r\n") - self.assertEquals(b"", self.reply()) - - def test_bulk_string(self): - self.reader.feed(b"$5\r\nhello\r\n") - self.assertEquals(b"hello", self.reply()) - - def test_bulk_string_without_encoding(self): - snowman = b"\xe2\x98\x83" - self.reader.feed(b"$3\r\n" + snowman + b"\r\n") - self.assertEquals(snowman, self.reply()) - - def test_bulk_string_with_encoding(self): - snowman = b"\xe2\x98\x83" - self.reader = hiredis.Reader(encoding="utf-8") - self.reader.feed(b"$3\r\n" + snowman + b"\r\n") - self.assertEquals(snowman.decode("utf-8"), self.reply()) - - def test_decode_errors_defaults_to_strict(self): - self.reader = hiredis.Reader(encoding="utf-8") - self.reader.feed(b"+\x80\r\n") - self.assertRaises(UnicodeDecodeError, self.reader.gets) - - def test_decode_error_with_ignore_errors(self): - self.reader = hiredis.Reader(encoding="utf-8", errors="ignore") - self.reader.feed(b"+\x80value\r\n") - self.assertEquals("value", self.reader.gets()) - - def test_decode_error_with_surrogateescape_errors(self): - self.reader = hiredis.Reader(encoding="utf-8", errors="surrogateescape") - self.reader.feed(b"+\x80value\r\n") - self.assertEquals("\udc80value", self.reader.gets()) - - def test_invalid_encoding(self): - self.assertRaises(LookupError, hiredis.Reader, encoding="unknown") - - def test_invalid_encoding_error_handler(self): - self.assertRaises(LookupError, hiredis.Reader, errors="unknown") - - def test_should_decode_false_flag_prevents_decoding(self): - snowman = b"\xe2\x98\x83" - self.reader = hiredis.Reader(encoding="utf-8") - self.reader.feed(b"$3\r\n" + snowman + b"\r\n") - self.reader.feed(b"$3\r\n" + snowman + b"\r\n") - self.assertEquals(snowman, self.reader.gets(False)) - self.assertEquals(snowman.decode("utf-8"), self.reply()) - - def test_should_decode_true_flag_decodes_as_normal(self): - snowman = b"\xe2\x98\x83" - self.reader = hiredis.Reader(encoding="utf-8") - self.reader.feed(b"$3\r\n" + snowman + b"\r\n") - self.assertEquals(snowman.decode("utf-8"), self.reader.gets(True)) - - def test_set_encoding_with_different_encoding(self): - snowman_utf8 = b"\xe2\x98\x83" - snowman_utf16 = b"\xff\xfe\x03&" - self.reader = hiredis.Reader(encoding="utf-8") - self.reader.feed(b"$3\r\n" + snowman_utf8 + b"\r\n") - self.reader.feed(b"$4\r\n" + snowman_utf16 + b"\r\n") - self.assertEquals(snowman_utf8.decode('utf-8'), self.reader.gets()) - self.reader.set_encoding(encoding="utf-16", errors="strict") - self.assertEquals(snowman_utf16.decode('utf-16'), self.reader.gets()) - - def test_set_encoding_to_not_decode(self): - snowman = b"\xe2\x98\x83" - self.reader = hiredis.Reader(encoding="utf-8") - self.reader.feed(b"$3\r\n" + snowman + b"\r\n") - self.reader.feed(b"$3\r\n" + snowman + b"\r\n") - self.assertEquals(snowman.decode('utf-8'), self.reader.gets()) - self.reader.set_encoding(encoding=None, errors=None) - self.assertEquals(snowman, self.reader.gets()) - - def test_set_encoding_invalid_encoding(self): - self.reader = hiredis.Reader(encoding="utf-8") - self.assertRaises(LookupError, self.reader.set_encoding, encoding="unknown") - - def test_set_encoding_invalid_error_handler(self): - self.reader = hiredis.Reader(encoding="utf-8") - self.assertRaises(LookupError, self.reader.set_encoding, encoding="utf-8", errors="unknown") - - def test_null_multi_bulk(self): - self.reader.feed(b"*-1\r\n") - self.assertEquals(None, self.reply()) - - def test_empty_multi_bulk(self): - self.reader.feed(b"*0\r\n") - self.assertEquals([], self.reply()) - - def test_multi_bulk(self): - self.reader.feed(b"*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n") - self.assertEquals([b"hello", b"world"], self.reply()) - - def test_nested_multi_bulk(self): - self.reader.feed(b"*2\r\n*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n$1\r\n!\r\n") - self.assertEquals([[b"hello", b"world"], b"!"], self.reply()) - - def test_nested_multi_bulk_depth(self): - self.reader.feed(b"*1\r\n*1\r\n*1\r\n*1\r\n$1\r\n!\r\n") - self.assertEquals([[[[b"!"]]]], self.reply()) - - def test_subclassable(self): - class TestReader(hiredis.Reader): - def __init__(self, *args, **kwargs): - super(TestReader, self).__init__(*args, **kwargs) - - reader = TestReader() - reader.feed(b"+ok\r\n") - self.assertEquals(b"ok", reader.gets()) - - def test_invalid_offset(self): - data = b"+ok\r\n" - self.assertRaises(ValueError, self.reader.feed, data, 6) - - def test_invalid_length(self): - data = b"+ok\r\n" - self.assertRaises(ValueError, self.reader.feed, data, 0, 6) - - def test_ok_offset(self): - data = b"blah+ok\r\n" - self.reader.feed(data, 4) - self.assertEquals(b"ok", self.reply()) - - def test_ok_length(self): - data = b"blah+ok\r\n" - self.reader.feed(data, 4, len(data)-4) - self.assertEquals(b"ok", self.reply()) - - def test_feed_bytearray(self): - self.reader.feed(bytearray(b"+ok\r\n")) - self.assertEquals(b"ok", self.reply()) - - def test_maxbuf(self): - defaultmaxbuf = self.reader.getmaxbuf() - self.reader.setmaxbuf(0) - self.assertEquals(0, self.reader.getmaxbuf()) - self.reader.setmaxbuf(10000) - self.assertEquals(10000, self.reader.getmaxbuf()) - self.reader.setmaxbuf(None) - self.assertEquals(defaultmaxbuf, self.reader.getmaxbuf()) - self.assertRaises(ValueError, self.reader.setmaxbuf, -4) - - def test_len(self): - self.assertEquals(0, self.reader.len()) - data = b"+ok\r\n" - self.reader.feed(data) - self.assertEquals(len(data), self.reader.len()) - - # hiredis reallocates and removes unused buffer once - # there is at least 1K of not used data. - calls = int((1024 / len(data))) + 1 - for i in range(calls): - self.reader.feed(data) - self.reply() - - self.assertEquals(5, self.reader.len()) - - def test_reader_has_data(self): - self.assertEquals(False, self.reader.has_data()) - data = b"+ok\r\n" - self.reader.feed(data) - self.assertEquals(True, self.reader.has_data()) - self.reply() - self.assertEquals(False, self.reader.has_data()) - - def test_custom_not_enough_data(self): - self.reader = hiredis.Reader(notEnoughData=Ellipsis) - assert self.reader.gets() is Ellipsis diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_reader.py b/tests/test_reader.py new file mode 100644 index 0000000..d78fb63 --- /dev/null +++ b/tests/test_reader.py @@ -0,0 +1,329 @@ +import hiredis +import pytest + +@pytest.fixture() +def reader(): + return hiredis.Reader() + +# def reply(): +# return reader.gets() + +def test_nothing(reader): + assert not reader.gets() + +def test_error_when_feeding_non_string(reader): + with pytest.raises(TypeError): + reader.feed(1) + +def test_protocol_error(reader): + reader.feed(b"x") + with pytest.raises(hiredis.ProtocolError): + reader.gets() + +def test_protocol_error_with_custom_class(): + r = hiredis.Reader(protocolError=RuntimeError) + r.feed(b"x") + with pytest.raises(RuntimeError): + r.gets() + +def test_protocol_error_with_custom_callable(): + + class CustomException(Exception): + pass + + r = hiredis.Reader(protocolError=lambda e: CustomException(e)) + r.feed(b"x") + with pytest.raises(CustomException): + r.gets() + +def test_fail_with_wrong_protocol_error_class(): + with pytest.raises(TypeError): + hiredis.Reader(protocolError="wrong") + +def test_faulty_protocol_error_class(): + def make_error(errstr): + 1 / 0 + r = hiredis.Reader(protocolError=make_error) + r.feed(b"x") + with pytest.raises(ZeroDivisionError): + r.gets() + +def test_error_string(reader): + reader.feed(b"-error\r\n") + error = reader.gets() + + assert isinstance(error, hiredis.ReplyError) + assert ("error", ) == error.args + +def test_error_string_with_custom_class(): + r = hiredis.Reader(replyError=RuntimeError) + r.feed(b"-error\r\n") + error = r.gets() + + assert isinstance(error, RuntimeError) + assert ("error", ) == error.args + +def test_error_string_with_custom_callable(): + class CustomException(Exception): + pass + + r= hiredis.Reader(replyError=lambda e: CustomException(e)) + r.feed(b"-error\r\n") + error = r.gets() + + assert isinstance(error, CustomException) + assert ("error", ) == error.args + +def test_error_string_with_non_utf8_chars(reader): + reader.feed(b"-error \xd1\r\n") + error = reader.gets() + + expected = "error \ufffd" + + assert isinstance(error, hiredis.ReplyError) + assert (expected,) == error.args + +def test_fail_with_wrong_reply_error_class(): + with pytest.raises(TypeError): + hiredis.Reader(replyError="wrong") + +def test_faulty_reply_error_class(): + def make_error(errstr): + 1 / 0 + + r= hiredis.Reader(replyError=make_error) + r.feed(b"-error\r\n") + with pytest.raises(ZeroDivisionError): + r.gets() + +def test_errors_in_nested_multi_bulk(reader): + reader.feed(b"*2\r\n-err0\r\n-err1\r\n") + + for r, error in zip(("err0", "err1"), reader.gets()): + assert isinstance(error, hiredis.ReplyError) + assert (r,) == error.args + +def test_errors_with_non_utf8_chars_in_nested_multi_bulk(reader): + reader.feed(b"*2\r\n-err\xd1\r\n-err1\r\n") + + expected = "err\ufffd" + + for r, error in zip((expected, "err1"), reader.gets()): + assert isinstance(error, hiredis.ReplyError) + assert (r,) == error.args + +def test_integer(reader): + value = 2**63-1 # Largest 64-bit signed integer + reader.feed((":%d\r\n" % value).encode("ascii")) + assert value == reader.gets() + +def test_float(reader): + value = -99.99 + reader.feed(b",%f\r\n" % value) + assert value == reader.gets() + +def test_boolean_true(reader): + reader.feed(b"#t\r\n") + assert reader.gets() + +def test_boolean_false(reader): + reader.feed(b"#f\r\n") + assert not reader.gets() + +def test_none(reader): + reader.feed(b"_\r\n") + assert reader.gets() is None + +def test_set(reader): + reader.feed(b"~3\r\n+tangerine\r\n_\r\n,10.5\r\n") + assert {b"tangerine", None, 10.5} == reader.gets() + +def test_dict(reader): + reader.feed(b"%2\r\n+radius\r\n,4.5\r\n+diameter\r\n:9\r\n") + assert {b"radius": 4.5, b"diameter": 9} == reader.gets() + +def test_vector(reader): + reader.feed(b">4\r\n+pubsub\r\n+message\r\n+channel\r\n+message\r\n") + assert [b"pubsub", b"message", b"channel", b"message"] == reader.gets() + +def test_verbatim_string(reader): + value = b"text" + reader.feed(b"=8\r\ntxt:%s\r\n" % value) + assert value == reader.gets() + +def test_status_string(reader): + reader.feed(b"+ok\r\n") + assert b"ok" == reader.gets() + +def test_empty_bulk_string(reader): + reader.feed(b"$0\r\n\r\n") + assert b"" == reader.gets() + +def test_bulk_string(reader): + reader.feed(b"$5\r\nhello\r\n") + assert b"hello" == reader.gets() + +def test_bulk_string_without_encoding(reader): + snowman = b"\xe2\x98\x83" + reader.feed(b"$3\r\n" + snowman + b"\r\n") + assert snowman == reader.gets() + +def test_bulk_string_with_encoding(): + snowman = b"\xe2\x98\x83" + r= hiredis.Reader(encoding="utf-8") + r.feed(b"$3\r\n" + snowman + b"\r\n") + assert snowman.decode("utf-8") == r.gets() + +def test_decode_errors_defaults_to_strict(): + r= hiredis.Reader(encoding="utf-8") + r.feed(b"+\x80\r\n") + with pytest.raises(UnicodeDecodeError): + r.gets() + +def test_decode_error_with_ignore_errors(): + r= hiredis.Reader(encoding="utf-8", errors="ignore") + r.feed(b"+\x80value\r\n") + assert "value" == r.gets() + +def test_decode_error_with_surrogateescape_errors(): + r= hiredis.Reader(encoding="utf-8", errors="surrogateescape") + r.feed(b"+\x80value\r\n") + assert "\udc80value" == r.gets() + +def test_invalid_encoding(): + with pytest.raises(LookupError): + hiredis.Reader(encoding="unknown") + +def test_should_decode_false_flag_prevents_decoding(): + snowman = b"\xe2\x98\x83" + r = hiredis.Reader(encoding="utf-8") + r.feed(b"$3\r\n" + snowman + b"\r\n") + r.feed(b"$3\r\n" + snowman + b"\r\n") + assert snowman == r.gets(False) + assert snowman.decode() == r.gets() + +def test_should_decode_true_flag_decodes_as_normal(): + snowman = b"\xe2\x98\x83" + r= hiredis.Reader(encoding="utf-8") + r.feed(b"$3\r\n" + snowman + b"\r\n") + assert snowman.decode() == r.gets(True) + +def test_set_encoding_with_different_encoding(): + snowman_utf8 = b"\xe2\x98\x83" + snowman_utf16 = b"\xff\xfe\x03&" + r= hiredis.Reader(encoding="utf-8") + r.feed(b"$3\r\n" + snowman_utf8 + b"\r\n") + r.feed(b"$4\r\n" + snowman_utf16 + b"\r\n") + assert snowman_utf8.decode() == r.gets() + r.set_encoding(encoding="utf-16", errors="strict") + assert snowman_utf16.decode('utf-16') == r.gets() + +def test_set_encoding_to_not_decode(): + snowman = b"\xe2\x98\x83" + r= hiredis.Reader(encoding="utf-8") + r.feed(b"$3\r\n" + snowman + b"\r\n") + r.feed(b"$3\r\n" + snowman + b"\r\n") + assert snowman.decode() == r.gets() + r.set_encoding(encoding=None, errors=None) + assert snowman == r.gets() + +def test_set_encoding_invalid_encoding(): + r= hiredis.Reader(encoding="utf-8") + with pytest.raises(LookupError): + r.set_encoding("unknown") + +def test_set_encoding_invalid_error_handler(): + r = hiredis.Reader(encoding="utf-8") + with pytest.raises(LookupError): + r.set_encoding(encoding="utf-8", errors="unknown") + +def test_null_multi_bulk(reader): + reader.feed(b"*-1\r\n") + assert reader.gets() is None + +def test_empty_multi_bulk(reader): + reader.feed(b"*0\r\n") + assert reader.gets() == [] + +def test_multi_bulk(reader): + reader.feed(b"*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n") + assert [b"hello", b"world"] == reader.gets() + +def test_nested_multi_bulk(reader): + reader.feed(b"*2\r\n*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n$1\r\n!\r\n") + assert [[b"hello", b"world"], b"!"] == reader.gets() + +def test_nested_multi_bulk_depth(reader): + reader.feed(b"*1\r\n*1\r\n*1\r\n*1\r\n$1\r\n!\r\n") + assert [[[[b"!"]]]] == reader.gets() + +def test_subclassable(reader): + + class TestReader(hiredis.Reader): + pass + + reader = TestReader() + reader.feed(b"+ok\r\n") + assert b"ok" == reader.gets() + +def test_invalid_offset(reader): + data = b"+ok\r\n" + with pytest.raises(ValueError): + reader.feed(data, 6) + +def test_invalid_length(reader): + data = b"+ok\r\n" + with pytest.raises(ValueError): + reader.feed(data, 0, 6) + +def test_ok_offset(reader): + data = b"blah+ok\r\n" + reader.feed(data, 4) + assert b"ok" == reader.gets() + +def test_ok_length(reader): + data = b"blah+ok\r\n" + reader.feed(data, 4, len(data)-4) + assert b"ok" == reader.gets() + +def test_feed_bytearray(reader): + reader.feed(bytearray(b"+ok\r\n")) + assert b"ok" == reader.gets() + +def test_maxbuf(reader): + defaultmaxbuf = reader.getmaxbuf() + reader.setmaxbuf(0) + assert 0 == reader.getmaxbuf() + reader.setmaxbuf(10000) + assert 10000 == reader.getmaxbuf() + reader.setmaxbuf(None) + assert defaultmaxbuf == reader.getmaxbuf() + with pytest.raises(ValueError): + reader.setmaxbuf(-4) + +def test_len(reader): + assert reader.len() == 0 + data = b"+ok\r\n" + reader.feed(data) + assert reader.len() == len(data) + + # hiredis reallocates and removes unused buffer once + # there is at least 1K of not used data. + calls = int((1024 / len(data))) + 1 + for i in range(calls): + reader.feed(data) + reader.gets() + + assert reader.len() == 5 + +def test_reader_has_data(reader): + assert reader.has_data() is False + data = b"+ok\r\n" + reader.feed(data) + assert reader.has_data() + reader.gets() + assert reader.has_data() is False + +def test_custom_not_enough_data(): + r = hiredis.Reader(notEnoughData=Ellipsis) + assert r.gets() == Ellipsis diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..cbe3746 --- /dev/null +++ b/tox.ini @@ -0,0 +1,41 @@ +[tox] +minversion = 3.2.0 +requires = tox-docker +envlist = linters + +[isort] +profile = black +multi_line_output = 3 + +[testenv:linters] +deps_files = dev_requirements.txt +docker = +commands = + flake8 + black --target-version py37 --check --diff . + isort --check-only --diff . + vulture redis whitelist.py --min-confidence 80 +skipsdist = true +skip_install = true + + +[flake8] +max-line-length = 88 +exclude = + *.egg-info, + *.pyc, + .git, + .tox, + .venv*, + build, + docs/*, + dist, + docker, + venv*, + .venv*, + whitelist.py +ignore = + F405 + W503 + E203 + E126