Skip to content

Commit

Permalink
Fix #24 + other improvements.
Browse files Browse the repository at this point in the history
- Use weakrefs to refer to a bidict's inverse to no longer create a reference
  cycle. Fixes #24.
- Rename fwd_cls to fwdm_cls.
- Rename inv_cls to invm_cls.
- inv_cls now returns the inverse class of the bidict, not its invm mapping.
- Rename isinv to _isinv.
  • Loading branch information
jab committed Dec 7, 2017
1 parent c7002bf commit fb32b56
Show file tree
Hide file tree
Showing 6 changed files with 125 additions and 67 deletions.
42 changes: 42 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,48 @@ Changelog
.. include:: release-notifications.rst.inc


0.15.0 (not yet released)
-------------------------

- Use weakrefs to refer to a bidict's inverse internally,
no longer creating a reference cycle.
Memory for a bidict that you create can now be reclaimed
as soon as you no longer hold any references to it.
Fixes `#24 <https://github.com/jab/bidict/issues/20>`_.

Breaking API Changes
++++++++++++++++++++

- Rename ``fwd_cls`` → :attr:`fwdm_cls <bidict.frozenbidict.fwdm_cls>`

- Rename ``inv_cls`` → :attr:`invm_cls <bidict.frozenbidict.invm_cls>`

:attr:`inv_cls <bidict.frozenbidict.inv_cls>`
now refers to a new classmethod that returns
the computed inverse bidict class,
not the user-overridable class of the backing inverse mapping.

This enabled improving the logic if you specify a different
:attr:`fwdm_cls <bidict.frozenbidict.fwdm_cls>` and
:attr:`invm_cls <bidict.frozenbidict.invm_cls>`
in a custom bidict subclass,
as in the :ref:`sorted-bidict-recipes`:
bidict now dynamically computes the
:attr:`inv_cls <bidict.frozenbidict.inv_cls>`
of your custom bidict to have the inverse
:attr:`fwdm_cls <bidict.frozenbidict.fwdm_cls>` and
:attr:`invm_cls <bidict.frozenbidict.invm_cls>`
of your custom bidict.

If creating a new instance of such a custom bidict
from the inverse of an existing instance,
the :attr:`fwdm_cls <bidict.frozenbidict.fwdm_cls>`
and :attr:`invm_cls <bidict.frozenbidict.invm_cls>`
of the new instance are no longer incorrectly swapped.

- Rename ``isinv`` to ``_isinv``.


0.14.2 (2017-12-06)
-------------------

Expand Down
2 changes: 1 addition & 1 deletion bidict/_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class BidirectionalMapping(Mapping): # pylint: disable=abstract-method
.. py:attribute:: inv
The inverse mapping.
The inverse bidirectional mapping.
.. py:attribute:: _subclsattrs
Expand Down
100 changes: 62 additions & 38 deletions bidict/_frozen.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""Implements :class:`frozenbidict`."""

from collections import ItemsView
from weakref import ref

from ._abc import BidirectionalMapping
from ._dup import RAISE, OVERWRITE, IGNORE
Expand Down Expand Up @@ -37,20 +38,6 @@ class frozenbidict(BidirectionalMapping): # noqa: N801
Also serves as a base class for the other bidict types.
.. py:attribute:: fwd_cls
The :class:`Mapping <collections.abc.Mapping>` type
used for the backing :attr:`fwdm` mapping,
Defaults to :class:`dict`.
Override this if you need different behavior.
.. py:attribute:: inv_cls
The :class:`Mapping <collections.abc.Mapping>` type
used for the backing :attr:`invm` mapping.
Defaults to :class:`dict`.
Override this if you need different behavior.
.. py:attribute:: on_dup_key
The default :class:`DuplicationPolicy` used in the event that an item
Expand Down Expand Up @@ -88,43 +75,81 @@ class frozenbidict(BidirectionalMapping): # noqa: N801
The backing :class:`Mapping <collections.abc.Mapping>`
storing the inverse mapping data (*value* → *key*).
.. py:attribute:: isinv
.. py:attribute:: fwdm_cls
:class:`bool` representing whether this bidict is the inverse of some
other bidict which has already been created. If True, the meaning of
:attr:`fwd_cls` and :attr:`inv_cls` is swapped. This enables
the inverse of a bidict specifying a different :attr:`fwd_cls` and
:attr:`inv_cls` to be passed back into its constructor such that
the resulting copy has its :attr:`fwd_cls` and :attr:`inv_cls`
set correctly.
The :class:`Mapping <collections.abc.Mapping>` type
used for the backing :attr:`fwdm` mapping,
Defaults to :class:`dict`.
Override this if you need different behavior.
.. py:attribute:: invm_cls
The :class:`Mapping <collections.abc.Mapping>` type
used for the backing :attr:`invm` mapping.
Defaults to :class:`dict`.
Override this if you need different behavior.
"""

on_dup_key = OVERWRITE
on_dup_val = RAISE
on_dup_kv = None
fwd_cls = dict
inv_cls = dict
fwdm_cls = dict
invm_cls = dict

def __init__(self, *args, **kw):
"""Like dict's ``__init__``."""
self.isinv = getattr(args[0], 'isinv', False) if args else False
self.fwdm = self.inv_cls() if self.isinv else self.fwd_cls()
self.invm = self.fwd_cls() if self.isinv else self.inv_cls()
self.itemsview = ItemsView(self)
self.fwdm = self.fwdm_cls()
self.invm = self.invm_cls()
self._init_inv() # lgtm [py/init-calls-subclass]
if args or kw:
self._update(True, self.on_dup_key, self.on_dup_val, self.on_dup_kv, *args, **kw)

@classmethod
def inv_cls(cls):
"""Return the inverse of this bidict class (with fwdm_cls and invm_cls swapped)."""
if cls.fwdm_cls is cls.invm_cls:
return cls
if not getattr(cls, '_inv_cls', None):
class _Inv(cls):
fwdm_cls = cls.invm_cls
invm_cls = cls.fwdm_cls
inv_cls = cls
_Inv.__name__ = cls.__name__
_Inv.__doc__ = cls.__doc__
cls._inv_cls = _Inv
return cls._inv_cls

def _init_inv(self):
inv = object.__new__(self.__class__)
inv.isinv = not self.isinv
inv.fwd_cls = self.inv_cls
inv.inv_cls = self.fwd_cls
self._inv = inv = object.__new__(self.inv_cls())
inv.fwdm_cls = self.invm_cls
inv.invm_cls = self.fwdm_cls
inv.fwdm = self.invm
inv.invm = self.fwdm
inv.itemsview = ItemsView(inv)
inv.inv = self
self.inv = inv
inv._invref = ref(self) # pylint: disable=protected-access
inv._inv = self._invref = None # pylint: disable=protected-access

@property
def _isinv(self):
return self._inv is None

@property
def inv(self):
"""The inverse of this bidict."""
if self._inv is not None:
return self._inv
inv = self._invref() # pylint: disable=E1102
if inv is not None:
return inv
# Refcount of referent must have dropped to zero, as in `bidict().inv.inv`. Init a new one.
self._init_inv()
return self._inv

@property
def __dict_pickle_safe__(self):
return dict(self.__dict__, _invref=None)

def __reduce__(self):
return self.__class__, (), self.__dict_pickle_safe__

def __repr__(self):
tmpl = self.__class__.__name__ + '('
Expand All @@ -147,7 +172,7 @@ def __hash__(self):
"""
if getattr(self, '_hash', None) is None: # pylint: disable=protected-access
# pylint: disable=protected-access,attribute-defined-outside-init
self._hash = self.itemsview._hash()
self._hash = ItemsView(self)._hash()
return self._hash

def __eq__(self, other):
Expand Down Expand Up @@ -301,7 +326,6 @@ def copy(self):
"""Like :py:meth:`dict.copy`."""
# This should be faster than ``return self.__class__(self)``.
copy = object.__new__(self.__class__)
copy.isinv = self.isinv
copy.fwdm = self.fwdm.copy()
copy.invm = self.invm.copy()
copy._init_inv() # pylint: disable=protected-access
Expand All @@ -326,4 +350,4 @@ def copy(self):

def viewitems(self):
"""Like dict's ``viewitems``."""
return self.itemsview
return ItemsView(self)
7 changes: 4 additions & 3 deletions bidict/_named.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,16 @@ def namedbidict(typename, keyname, valname, base_type=bidict):
raise ValueError('"%s" does not match pattern %s' %
(name, _LEGALNAMEPAT))

getfwd = lambda self: self.inv if self.isinv else self
getfwd = lambda self: self.inv if self._isinv else self # pylint: disable=protected-access
getfwd.__name__ = valname + '_for'
getfwd.__doc__ = u'%s forward %s: %s → %s' % (typename, base_type.__name__, keyname, valname)

getinv = lambda self: self if self.isinv else self.inv
getinv = lambda self: self if self._isinv else self.inv # pylint: disable=protected-access
getinv.__name__ = keyname + '_for'
getinv.__doc__ = u'%s inverse %s: %s → %s' % (typename, base_type.__name__, valname, keyname)

__reduce__ = lambda self: (_make_empty, (typename, keyname, valname, base_type), self.__dict__)
__reduce__ = lambda self: (
_make_empty, (typename, keyname, valname, base_type), self.__dict_pickle_safe__)
__reduce__.__name__ = '__reduce__'
__reduce__.__doc__ = 'helper for pickle'

Expand Down
1 change: 0 additions & 1 deletion bidict/_ordered.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ def copy(self):
"""Like :meth:`collections.OrderedDict.copy`."""
# This should be faster than ``return self.__class__(self)``.
copy = object.__new__(self.__class__)
copy.isinv = self.isinv
sntl = _make_sentinel()
fwdm = {}
invm = {}
Expand Down
40 changes: 16 additions & 24 deletions docs/sortedbidicts.rst.inc
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.. _sortedbidicts:
.. _sorted-bidict-recipes:

Sorted Bidict Recipes
#####################
Expand All @@ -10,9 +10,9 @@ but the excellent
`sortedcollections <http://www.grantjenks.com/docs/sortedcollections/>`_
libraries do.
Armed with these along with bidict's
:attr:`fwd_cls <bidict.BidictBase.fwd_cls>`
:attr:`fwdm_cls <bidict.BidictBase.fwdm_cls>`
and
:attr:`inv_cls <bidict.BidictBase.inv_cls>`
:attr:`invm_cls <bidict.BidictBase.invm_cls>`
attributes,
creating a sorted bidict type is dead simple::
Expand All @@ -22,14 +22,14 @@ creating a sorted bidict type is dead simple::
>>> # and whose inverse items stay sorted by *their* keys (i.e. it and
>>> # its inverse iterate over their items in different orders):
>>> class SortedBidict1(bidict.bidict):
... fwd_cls = sortedcontainers.SortedDict
... inv_cls = sortedcontainers.SortedDict
>>> class KeySortedBidict(bidict.bidict):
... fwdm_cls = sortedcontainers.SortedDict
... invm_cls = sortedcontainers.SortedDict
... __reversed__ = lambda self: reversed(self.fwdm)
>>> b = SortedBidict1({'Tokyo': 'Japan', 'Cairo': 'Egypt'})
>>> b = KeySortedBidict({'Tokyo': 'Japan', 'Cairo': 'Egypt'})
>>> b
SortedBidict1([('Cairo', 'Egypt'), ('Tokyo', 'Japan')])
KeySortedBidict([('Cairo', 'Egypt'), ('Tokyo', 'Japan')])
>>> b['Lima'] = 'Peru'
Expand All @@ -48,17 +48,17 @@ creating a sorted bidict type is dead simple::

>>> import sortedcollections

>>> class SortedBidict2(bidict.bidict):
... fwd_cls = sortedcontainers.SortedDict
... inv_cls = sortedcollections.ValueSortedDict
>>> class FwdKeySortedBidict(bidict.bidict):
... fwdm_cls = sortedcontainers.SortedDict
... invm_cls = sortedcollections.ValueSortedDict
... __reversed__ = lambda self: reversed(self.fwdm)

>>> element_by_atomic_number = SortedBidict2({
>>> element_by_atomic_number = FwdKeySortedBidict({
... 3: 'lithium', 1: 'hydrogen', 2: 'helium'})

>>> # stays sorted by key:
>>> element_by_atomic_number
SortedBidict2([(1, 'hydrogen'), (2, 'helium'), (3, 'lithium')])
FwdKeySortedBidict([(1, 'hydrogen'), (2, 'helium'), (3, 'lithium')])

>>> # .inv stays sorted by value:
>>> list(element_by_atomic_number.inv.items())
Expand All @@ -69,15 +69,7 @@ creating a sorted bidict type is dead simple::
>>> list(element_by_atomic_number.inv.items())
[('hydrogen', 1), ('helium', 2), ('lithium', 3), ('beryllium', 4)]

>>> # order is preserved correctly when passing .inv back into constructor:
>>> atomic_number_by_element = SortedBidict2(element_by_atomic_number.inv)
>>> list(atomic_number_by_element.items()) == list(element_by_atomic_number.inv.items())
True
>>> # copies of .inv preserve order correctly too:
>>> list(element_by_atomic_number.inv.copy().items()) == list(atomic_number_by_element.items())
True

>>> # To pass method calls through to the _fwd SortedDict when not present
>>> # To pass method calls through to the fwdm SortedDict when not present
>>> # on the bidict instance, provide a custom __getattribute__ method:
>>> def __getattribute__(self, name):
... try:
Expand All @@ -88,8 +80,8 @@ creating a sorted bidict type is dead simple::
... except AttributeError:
... raise e

>>> SortedBidict2.__getattribute__ = __getattribute__
>>> FwdKeySortedBidict.__getattribute__ = __getattribute__

>>> # bidict has no .peekitem attr, so the call is passed through to _fwd:
>>> # bidict has no .peekitem attr, so the call is passed through to fwdm:
>>> element_by_atomic_number.peekitem()
(4, 'beryllium')

0 comments on commit fb32b56

Please sign in to comment.