Skip to content

Commit

Permalink
Switch to pytest for unit tests (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
tfpf authored Dec 14, 2024
1 parent 67ac6b6 commit 5a9a2ad
Show file tree
Hide file tree
Showing 7 changed files with 248 additions and 314 deletions.
3 changes: 0 additions & 3 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ jobs:
- uses: pypa/[email protected]
with:
output-dir: wheelhouse
env:
CIBW_BUILD: cp* pp*
CIBW_TEST_COMMAND: python -m unittest discover -s {package}/tests -t {package}
- uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.os }}-${{ strategy.job-index }}
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
/dist/
/src/*.egg-info
/src/*.so

__pycache__
10 changes: 8 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pysorteddict"
version = "0.1.0"
version = "0.2.0"
authors = [
{name = "Vishal Pankaj Chandratreya"},
]
Expand Down Expand Up @@ -36,14 +36,20 @@ classifiers = [
"Repository" = "https://github.com/tfpf/pysorteddict"
"Bug Tracker" = "https://github.com/tfpf/pysorteddict/issues"

[tool.cibuildwheel]
build = "cp* pp*"
test-command = "pytest {package}"
test-requires = ["pytest"]

# No use of an editable installation. If the code is edited, it has to be
# recompiled, and the package has to be reinstalled.
[tool.hatch.envs.default]
dev-mode = false
dependencies = ["pytest"]

[tool.ruff]
line-length = 119

[tool.ruff.lint.per-file-ignores]
"examples/*" = ["T201"]
"tests/*" = ["PT009", "PT027", "S311"]
"tests/*" = ["PLR2004", "S101", "S311"]
25 changes: 0 additions & 25 deletions tests/test_int_keys.py

This file was deleted.

32 changes: 14 additions & 18 deletions tests/test_invalid_construction.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
import unittest
import pytest

from pysorteddict import SortedDict


class TestInvalidConstruction(unittest.TestCase):
"""Test invalid construction of a sorted dictionary."""
def test_no_arguments():
with pytest.raises(TypeError) as ctx:
SortedDict()
assert ctx.value.args[0] == "function missing required argument 'key_type' (pos 1)"

@classmethod
def setUpClass(cls):
cls.missing_argument = "function missing required argument 'key_type' (pos 1)"
cls.wrong_argument = "constructor argument must be a supported type"

def test_construct_without_argument(self):
with self.assertRaises(TypeError) as ctx:
SortedDict()
self.assertEqual(self.missing_argument, ctx.exception.args[0])
def test_superfluous_arguments():
with pytest.raises(TypeError) as ctx:
SortedDict(object, object)
assert ctx.value.args[0] == "function takes at most 1 argument (2 given)"

def test_construct_with_object_instance(self):
with self.assertRaises(TypeError) as ctx:
SortedDict(object())
self.assertEqual(self.wrong_argument, ctx.exception.args[0])


if __name__ == "__main__":
unittest.main()
@pytest.mark.parametrize("key_type", [object, object(), 63, 5.31, "effort", b"salt", ["hear", 0x5EE], (1.61, "taste")])
def test_wrong_type(key_type):
with pytest.raises(TypeError) as ctx:
SortedDict(key_type)
assert ctx.value.args[0] == "constructor argument must be a supported type"
224 changes: 224 additions & 0 deletions tests/test_methods.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import builtins
import platform
import random
import sys

import pytest

from pysorteddict import SortedDict

# Reference counting is specific to CPython, so record this for later.
cpython = platform.python_implementation() == "CPython"


class Resources:
"""
Store resources used to generate similar test cases for different key
types.
"""

def __init__(self, key_type: type):
self.key_type = key_type
self.key_subtype = type("sub" + self.key_type.__name__, (self.key_type,), {})

self.rg = random.Random(__name__)
self.keys = [self.gen() for _ in range(1000)]
self.values = [self.gen() for _ in self.keys]
self.normal_dict = dict(zip(self.keys, self.values, strict=True))

sorted_dict = SortedDict(self.key_type)
for key, value in zip(self.keys, self.values, strict=True):
sorted_dict[key] = value
self.sorted_dicts = [sorted_dict, sorted_dict.copy()]

# Store the reference count of an item in a list at the position at
# which it appears in the normal dictionary. The reference counts are
# all 4, but querying the reference count increases it, so I store 5.
# Whenever a test changes the reference count of any item, I set the
# new reference count at its index.
self.keys = [*self.normal_dict]
self.keys_refcounts = [5] * len(self.keys)
self.values = [*self.normal_dict.values()]
self.values_refcounts = [5] * len(self.values)

def gen(self, *, small: bool = True) -> int:
"""
Generate a key or value for a dictionary. It will be a new object (i.e.
not an interned one).
:param large: Whether to generate a small or large key/value. (A small
one will never be equal to a large one.)
:return: Random result.
"""
match self.key_type:
# The pattern must be non-capturing (otherwise, it raises a syntax
# error because the remaining patterns become unreachable). Hence,
# whenever the pattern is an existing name which can be shadowed,
# it has to be written like this.
case builtins.int:
if small:
return self.rg.randrange(1000, 2000)
return self.rg.randrange(2000, 3000)

case _:
raise RuntimeError


@pytest.fixture
def resources(request):
"""
Create test resources for the given key type (passed as a parameter to this
fixture).
"""
resources = Resources(request.param)
yield resources

# Tearing down: verify the reference counts.
if cpython:
for observed, expected in zip(map(sys.getrefcount, resources.keys), resources.keys_refcounts, strict=False):
assert observed == expected
for observed, expected in zip(
map(sys.getrefcount, resources.values), resources.values_refcounts, strict=False
):
assert observed == expected


@pytest.fixture
def sorted_dict(request, resources):
"""
Obtain either the sorted dictionary or its copy based on the index (passed
as a parameter to this fixture). The aim is to test both it and its copy
with the same rigour.
"""
sorted_dict = resources.sorted_dicts[request.param]
yield sorted_dict

# Tearing down: verify some non-mutating methods.
assert len(sorted_dict) == len(resources.normal_dict)
assert str(sorted_dict) == str(dict(sorted(resources.normal_dict.items())))
assert sorted_dict.items() == sorted(resources.normal_dict.items())
assert sorted_dict.keys() == sorted(resources.normal_dict)
assert sorted_dict.values() == [item[1] for item in sorted(resources.normal_dict.items())]


# Run each test with each key type, and on the sorted dictionary and its copy.
pytestmark = [
pytest.mark.parametrize("resources", [int], indirect=True),
pytest.mark.parametrize("sorted_dict", [0, 1], ids=["original", "copy"], indirect=True),
]


def test_contains_wrong_type(resources, sorted_dict):
assert resources.key_subtype() not in sorted_dict


def test_contains_no(resources, sorted_dict):
key = resources.gen(small=False)
assert key not in sorted_dict

if cpython:
assert sys.getrefcount(key) == 2


def test_contains_yes(resources, sorted_dict):
key = resources.rg.choice(resources.keys)
assert key in sorted_dict

if cpython:
assert sys.getrefcount(key) == 6


def test_getitem_wrong_type(resources, sorted_dict):
with pytest.raises(TypeError) as ctx:
sorted_dict[resources.key_subtype()]
assert ctx.value.args[0] == f"key must be of type {resources.key_type!r}"


def test_getitem_missing(resources, sorted_dict):
key = resources.gen(small=False)
with pytest.raises(KeyError) as ctx:
sorted_dict[key]
assert ctx.value.args[0] == key

if cpython:
assert sys.getrefcount(key) == 3


def test_getitem_found(resources, sorted_dict):
key = resources.rg.choice(resources.keys)
value = sorted_dict[key]
assert value == resources.normal_dict[key]

if cpython:
assert sys.getrefcount(key) == 6
assert sys.getrefcount(value) == 6


def test_delitem_wrong_type(resources, sorted_dict):
with pytest.raises(TypeError) as ctx:
del sorted_dict[resources.key_subtype()]
assert ctx.value.args[0] == f"key must be of type {resources.key_type!r}"


def test_delitem_missing(resources, sorted_dict):
key = resources.gen(small=False)
with pytest.raises(KeyError) as ctx:
del sorted_dict[key]
assert ctx.value.args[0] == key

if cpython:
assert sys.getrefcount(key) == 3


def test_delitem_found(resources, sorted_dict):
idx, key = resources.rg.choice([*enumerate(resources.keys)])
del resources.normal_dict[key]
del sorted_dict[key]
assert key not in sorted_dict

if cpython:
resources.keys_refcounts[idx] -= 2
resources.values_refcounts[idx] -= 2


def test_setitem_wrong_type(resources, sorted_dict):
value = resources.gen()
with pytest.raises(TypeError) as ctx:
sorted_dict[resources.key_subtype()] = value
assert ctx.value.args[0] == f"key must be of type {resources.key_type!r}"

if cpython:
assert sys.getrefcount(value) == 2


def test_setitem_insert(resources, sorted_dict):
key, value = resources.gen(small=False), resources.gen()
resources.normal_dict[key] = value
sorted_dict[key] = value
assert sorted_dict[key] == value

if cpython:
assert sys.getrefcount(key) == 4
assert sys.getrefcount(value) == 4


def test_setitem_overwrite(resources, sorted_dict):
idx, key = resources.rg.choice([*enumerate(resources.keys)])
value = resources.gen()
resources.normal_dict[key] = value
sorted_dict[key] = value
assert sorted_dict[key] == value

if cpython:
assert sys.getrefcount(value) == 4
resources.values_refcounts[idx] -= 2


def test_clear(resources, sorted_dict):
resources.normal_dict.clear()
sorted_dict.clear()

if cpython:
resources.keys_refcounts = [3] * len(resources.keys)
resources.values_refcounts = [3] * len(resources.values)
Loading

0 comments on commit 5a9a2ad

Please sign in to comment.