bidict/tests/properties/test_properties.py

428 lines
16 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2009-2021 Joshua Bronson. All Rights Reserved.
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
"""Property-based tests using https://hypothesis.readthedocs.io."""
import gc
import pickle
from copy import deepcopy
from collections import OrderedDict
from collections.abc import Iterable
from itertools import tee
from platform import python_implementation
from weakref import ref
import pytest
from hypothesis import assume, example, given
from bidict import (
BidictException,
DROP_OLD, RAISE, OnDup,
OrderedBidictBase, OrderedBidict, bidict, namedbidict,
inverted,
KeyDuplicationError, ValueDuplicationError, KeyAndValueDuplicationError,
)
from bidict._iter import _iteritems_args_kw
from . import _strategies as st
require_cpython_gc = pytest.mark.skipif(
python_implementation() != 'CPython',
reason='Requires CPython GC behavior',
)
@given(st.BIDICTS, st.NON_MAPPINGS)
def test_unequal_to_non_mapping(bi, not_a_mapping):
"""Bidicts and their inverses should be unequal to non-mappings."""
assert bi != not_a_mapping
assert bi.inv != not_a_mapping
assert not bi == not_a_mapping
assert not bi.inv == not_a_mapping
@given(st.BI_AND_MAP_FROM_DIFF_ITEMS)
def test_unequal_to_mapping_with_different_items(bi_and_map_from_diff_items):
"""Bidicts should be unequal to mappings containing different items."""
bi, mapping = bi_and_map_from_diff_items
assert bi != mapping
assert not bi == mapping
@given(st.BI_AND_MAP_FROM_SAME_ND_ITEMS)
def test_equal_to_mapping_with_same_items(bi_and_map_from_same_items):
"""Bidicts should be equal to mappings created from the same non-duplicating items.
The bidict's inverse and the mapping's inverse should also be equal.
"""
bi, mapping = bi_and_map_from_same_items
assert bi == mapping
assert not bi != mapping
mapping_inv = OrderedDict((v, k) for (k, v) in mapping.items())
assert bi.inv == mapping_inv
assert not bi.inv != mapping_inv
@given(st.HBI_AND_HMAP_FROM_SAME_ND_ITEMS)
def test_equal_hashables_have_same_hash(hashable_bidict_and_mapping):
"""Hashable bidicts and hashable mappings that are equal should hash to the same value."""
bi, mapping = hashable_bidict_and_mapping
assert bi == mapping
assert hash(bi) == hash(mapping)
@given(st.BI_AND_MAP_FROM_SAME_ND_ITEMS)
def test_equals_order_sensitive(bi_and_map_from_same_items):
"""Ordered bidicts should be order-sensitive-equal to ordered mappings with same nondup items.
The bidict's inverse and the ordered mapping's inverse should also be order-sensitive-equal.
"""
bi, mapping = bi_and_map_from_same_items
assert bi.equals_order_sensitive(mapping)
mapping_inv = {v: k for (k, v) in mapping.items()}
assert bi.inv.equals_order_sensitive(mapping_inv)
@given(st.OBI_AND_OMAP_FROM_SAME_ITEMS_DIFF_ORDER)
def test_unequal_order_sensitive_same_items_different_order(ob_and_om):
"""Ordered bidicts should be order-sensitive-unequal to ordered mappings of diff-ordered items.
Where both were created from the same items where no key or value was duplicated,
but the items were ordered differently.
The bidict's inverse and the ordered mapping's inverse should also be order-sensitive-unequal.
"""
ob, om = ob_and_om
assert not ob.equals_order_sensitive(om)
om_inv = OrderedDict((v, k) for (k, v) in om.items())
assert not ob.inv.equals_order_sensitive(om_inv)
@given(st.ORDERED_BIDICTS, st.NON_MAPPINGS)
def test_unequal_order_sensitive_non_mapping(ob, not_a_mapping):
"""Ordered bidicts should be order-sensitive-unequal to ordered mappings of diff-ordered items.
Where both were created from the same items where no key or value was duplicated,
but the items were ordered differently.
The bidict's inverse and the ordered mapping's inverse should also be order-sensitive-unequal.
"""
assert not ob.equals_order_sensitive(not_a_mapping)
assert not ob.inv.equals_order_sensitive(not_a_mapping)
@given(st.MUTABLE_BIDICTS, st.DIFF_ATOMS, st.RANDOMS)
def test_setitem_with_dup_val_raises(bi, new_key, rand):
"""Setting an item whose value duplicates that of an existing item should raise ValueDuplicationError."""
ln = len(bi)
assume(ln > 2)
for b in (bi, bi.inv):
existing_val = rand.choice(list(b.inv))
with pytest.raises(ValueDuplicationError):
b[new_key] = existing_val
assert len(b) == len(b.inv) == ln
@given(st.MUTABLE_BIDICTS, st.RANDOMS)
def test_setitem_with_dup_key_val_raises(bi, rand):
"""Setting an item whose key and val duplicate two different existing items raises KeyAndValueDuplicationError."""
ln = len(bi)
assume(ln > 2)
for b in (bi, bi.inv):
existing_items = rand.sample(list(b.items()), 2)
existing_key = existing_items[0][0]
existing_val = existing_items[1][1]
with pytest.raises(KeyAndValueDuplicationError):
b[existing_key] = existing_val
assert len(b) == len(b.inv) == ln
@given(st.MUTABLE_BIDICTS, st.DIFF_ATOMS, st.RANDOMS)
def test_put_with_dup_key_raises(bi, new_val, rand):
"""Putting an item whose key duplicates that of an existing item should raise KeyDuplicationError."""
ln = len(bi)
assume(ln > 2)
for b in (bi, bi.inv):
existing_key = rand.choice(list(b))
with pytest.raises(KeyDuplicationError):
b.put(existing_key, new_val)
assert len(b) == len(b.inv) == ln
@given(st.BIDICTS)
def test_bijectivity(bi):
"""b[k] == v <==> b.inv[v] == k"""
for b in (bi, bi.inv):
assert all(b.inv[v] == k for (k, v) in b.items())
@given(st.MUTABLE_BIDICTS)
def test_cleared_bidicts_have_no_items(bi):
bi.clear()
assert not bi
assert len(bi) == 0
@given(st.BI_AND_CMPDICT_FROM_SAME_ITEMS, st.ARGS_BY_METHOD)
def test_consistency_after_method_call(bi_and_cmp_dict, args_by_method):
"""A bidict should be left in a consistent state after calling any method, even if it raises."""
bi_orig, cmp_dict_orig = bi_and_cmp_dict
for (_, methodname), args in args_by_method.items():
if not hasattr(bi_orig, methodname):
continue
bi = bi_orig.copy()
method = getattr(bi, methodname)
try:
result = method(*args)
except (KeyError, BidictException) as exc:
# Call should fail clean, i.e. bi should be in the same state it was before the call.
assertmsg = f'{method!r} did not fail clean: {exc!r}'
assert bi == bi_orig, assertmsg
assert bi.inv == bi_orig.inv, assertmsg
else:
# Should get the same result as calling the same method on the compare-to dict.
cmp_dict = cmp_dict_orig.copy()
cmp_dict_meth = getattr(cmp_dict, methodname, None)
if cmp_dict_meth:
cmp_result = cmp_dict_meth(*args)
if isinstance(cmp_result, Iterable):
coll = list if isinstance(bi, OrderedBidictBase) else set
result = coll(result)
cmp_result = coll(cmp_result)
assert result == cmp_result, f'methodname={methodname}, args={args!r}'
# Whether the call failed or succeeded, bi should pass consistency checks.
assert len(bi) == sum(1 for _ in bi.items())
assert len(bi.inv) == sum(1 for _ in bi.inv.items())
assert bi == dict(bi)
assert bi.inv == dict(bi.inv)
assert bi == OrderedDict((k, v) for (v, k) in bi.inv.items())
assert bi.inv == OrderedDict((v, k) for (k, v) in bi.items())
@given(st.MUTABLE_BIDICTS, st.L_PAIRS, st.ON_DUP)
# These test cases ensure coverage of all branches in [Ordered]BidictBase._undo_write
# (Hypothesis doesn't always generate examples that cover all the branches otherwise).
@example(bidict({1: 1, 2: 2}), [(1, 3), (1, 2)], OnDup(key=DROP_OLD, val=RAISE))
@example(bidict({1: 1, 2: 2}), [(3, 1), (2, 4)], OnDup(key=RAISE, val=DROP_OLD))
@example(bidict({1: 1, 2: 2}), [(1, 2), (1, 1)], OnDup(key=RAISE, val=RAISE, kv=DROP_OLD))
@example(OrderedBidict({1: 1, 2: 2}), [(1, 3), (1, 2)], OnDup(key=DROP_OLD, val=RAISE))
@example(OrderedBidict({1: 1, 2: 2}), [(3, 1), (2, 4)], OnDup(key=RAISE, val=DROP_OLD))
@example(OrderedBidict({1: 1, 2: 2}), [(1, 2), (1, 1)], OnDup(key=RAISE, val=RAISE, kv=DROP_OLD))
def test_putall_same_as_put_for_each_item(bi, items, on_dup):
"""*bi.putall(items) <==> for i in items: bi.put(i)* for all values of OnDup."""
check = bi.copy()
expect = bi.copy()
checkexc = None
expectexc = None
for (key, val) in items:
try:
expect.put(key, val, on_dup)
except BidictException as exc:
expectexc = type(exc)
expect = bi # Bulk updates fail clean -> roll back to original state.
break
try:
check.putall(items, on_dup)
except BidictException as exc:
checkexc = type(exc)
assert checkexc == expectexc
assert check == expect
assert check.inv == expect.inv
@given(st.BI_AND_MAP_FROM_SAME_ND_ITEMS)
def test_bidict_iter(bi_and_mapping):
"""iter(bi) should yield the keys in a bidict in insertion order."""
bi, mapping = bi_and_mapping
assert all(i == j for (i, j) in zip(bi, mapping))
@given(st.RBI_AND_RMAP_FROM_SAME_ND_ITEMS)
def test_bidict_reversed(rb_and_rd):
"""reversed(bi) should yield the keys in a bidict in reverse insertion order."""
rb, rd = rb_and_rd
assert all(i == j for (i, j) in zip(reversed(rb), reversed(rd)))
@given(st.FROZEN_BIDICTS)
def test_frozenbidicts_hashable(bi):
"""Frozen bidicts can be hashed and inserted into sets and mappings."""
assert hash(bi)
assert {bi}
assert {bi: bi}
@given(st.NAMEDBIDICT_NAMES_SOME_INVALID)
def test_namedbidict_raises_on_invalid_name(names):
""":func:`bidict.namedbidict` should raise if given invalid names."""
typename, keyname, valname = names
with pytest.raises(ValueError):
namedbidict(typename, keyname, valname)
@given(st.NAMEDBIDICT_NAMES_ALL_VALID)
def test_namedbidict_raises_on_same_keyname_as_valname(names):
""":func:`bidict.namedbidict` should raise if given same keyname as valname."""
typename, keyname, _ = names
with pytest.raises(ValueError):
namedbidict(typename, keyname, keyname)
@given(st.NAMEDBIDICT_NAMES_ALL_VALID, st.NON_BIDICT_MAPPING_TYPES)
def test_namedbidict_raises_on_invalid_base_type(names, invalid_base_type):
""":func:`bidict.namedbidict` should raise if given a non-bidict base_type."""
with pytest.raises(TypeError):
namedbidict(*names, base_type=invalid_base_type)
@given(st.NAMEDBIDICTS)
def test_namedbidict(nb):
"""Test :func:`bidict.namedbidict` custom accessors."""
valfor = getattr(nb, nb._valname + '_for')
keyfor = getattr(nb, nb._keyname + '_for')
assert all(valfor[key] == val for (key, val) in nb.items())
assert all(keyfor[val] == key for (key, val) in nb.items())
# The same custom accessors should work on the inverse.
inv = nb.inv
valfor = getattr(inv, nb._valname + '_for')
keyfor = getattr(inv, nb._keyname + '_for')
assert all(valfor[key] == val for (key, val) in nb.items())
assert all(keyfor[val] == key for (key, val) in nb.items())
@given(st.BIDICTS)
def test_bidict_isinv_getstate(bi):
"""All bidicts should provide ``_isinv`` and ``__getstate__``
(or else they won't fully work as a *base_type* for :func:`namedbidict`).
"""
bi._isinv # pylint: disable=pointless-statement
assert bi.__getstate__()
@require_cpython_gc
@given(bi_cls=st.BIDICT_TYPES)
def test_bidicts_freed_on_zero_refcount(bi_cls):
"""On CPython, the moment you have no more (strong) references to a bidict,
there are no remaining (internal) strong references to it
(i.e. no reference cycle was created between it and its inverse),
allowing the memory to be reclaimed immediately, even with GC disabled.
"""
gc.disable()
try:
bi = bi_cls()
weak = ref(bi)
assert weak() is not None
del bi
assert weak() is None
finally:
gc.enable()
@require_cpython_gc
@given(ob_cls=st.ORDERED_BIDICT_TYPES, init_items=st.I_PAIRS_NODUP)
def test_orderedbidict_nodes_freed_on_zero_refcount(ob_cls, init_items):
"""On CPython, the moment you have no more references to an ordered bidict,
the refcount of each of its internal nodes drops to 0
(i.e. the linked list of nodes does not create a reference cycle),
allowing the memory to be reclaimed immediately.
"""
gc.disable()
try:
ob = ob_cls(init_items)
node_refs = [ref(node) for node in ob._fwdm.values()]
assert all(r() is not None for r in node_refs)
del ob
assert all(r() is None for r in node_refs)
finally:
gc.enable()
@given(bi_cls=st.BIDICT_TYPES)
def test_slots(bi_cls):
"""See https://docs.python.org/3/reference/datamodel.html#notes-on-using-slots."""
stop_at = {object}
cls_by_slot = {}
for cls in bi_cls.__mro__:
if cls in stop_at:
break
slots = getattr(cls, '__slots__', None)
assert slots is not None, f'Expected {cls!r} to define __slots__'
for slot in slots:
seen_at = cls_by_slot.get(slot)
assert not seen_at, f'{seen_at!r} repeats slot {slot!r} declared first by {cls!r}'
cls_by_slot[slot] = cls
@given(st.BIDICTS)
def test_inv_aliases_inverse(bi):
"""bi.inv should alias bi.inverse."""
assert bi.inverse is bi.inv
assert bi.inv.inverse is bi.inverse.inv
@given(st.BIDICTS)
def test_pickle_roundtrips(bi):
"""A bidict should equal the result of unpickling its pickle."""
pickled = pickle.dumps(bi)
roundtripped = pickle.loads(pickled)
assert roundtripped is roundtripped.inv.inv
assert roundtripped == bi
assert roundtripped.inv == bi.inv
assert roundtripped.inv.inv == bi.inv.inv
@given(st.BIDICTS)
def test_deepcopy(bi):
"""A bidict should equal its deepcopy."""
cp = deepcopy(bi)
assert cp is not bi
assert cp.inv.inv is cp
assert cp.inv.inv is not bi
assert bi == cp
assert bi.inv == cp.inv
def test_iteritems_args_kw_raises_on_too_many_args():
""":func:`bidict._iteritems_args_kw` should raise if given too many arguments."""
with pytest.raises(TypeError):
_iteritems_args_kw('too', 'many', 'args')
@given(st.I_PAIRS, st.ODICTS_KW_PAIRS)
def test_iteritems_args_kw(arg0, kw):
""":func:`bidict._iteritems_args_kw` should work correctly."""
arg0_1, arg0_2 = tee(arg0)
it = _iteritems_args_kw(arg0_1, **kw)
# Consume the first `len(arg0)` pairs, checking that they match `arg0`.
assert all(check == expect for (check, expect) in zip(it, arg0_2))
with pytest.raises(StopIteration):
next(arg0_1) # Iterating `it` should have consumed all of `arg0_1`.
# Consume the remaining pairs, checking that they match `kw`.
# Once min PY version required is higher, can check that the order matches `kw` too.
assert all(kw[k] == v for (k, v) in it)
with pytest.raises(StopIteration):
next(it)
@given(st.L_PAIRS)
def test_inverted_pairs(pairs):
""":func:`bidict.inverted` should yield the inverses of a list of pairs."""
inv = [(v, k) for (k, v) in pairs]
assert list(inverted(pairs)) == inv
assert list(inverted(inverted(pairs))) == pairs
@given(st.BI_AND_MAP_FROM_SAME_ND_ITEMS)
def test_inverted_bidict(bi_and_mapping):
""":func:`bidict.inverted` should yield the inverse items of an ordered bidict."""
bi, mapping = bi_and_mapping
mapping_inv = {v: k for (k, v) in mapping.items()}
assert all(i == j for (i, j) in zip(inverted(bi), mapping_inv.items()))
assert all(i == j for (i, j) in zip(inverted(inverted(bi)), mapping.items()))