Repository: redis/hiredis-py (language: python)

Issue:
Bump hiredis version to 1.0.2
There's a security fix available for hiredis.

See:
*  CVE-2021-32765 - https://github.com/redis/hiredis/security/advisories/GHSA-hfm9-39pp-55p2
* https://github.com/redis/hiredis/blob/master/CHANGELOG.md


Discussion / hints:
CVE-2021-32765 is related to [a fix](https://github.com/redis/hiredis/compare/v1.0.0..v1.0.2#diff-b61215f4090069fa2ec48b946a1f19df25f63b1eb9e35d5bf71bd9128ae14498) of a `createArrayObject` function.

hiredis-py defines [its own version](https://github.com/redis/hiredis-py/blob/d4d701a06560ce76a4eaba3ac309ff70e37b3190/src/reader.c#L158-L171) of the function. So the CVE and the fix should not touch hiredis-py, I am not sure if any similar vulnerability exists in this package though.

Fix patch:
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/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/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


Test patch:
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/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