# -*- 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()))