Repository: sopel-irc/sopel

Issue:
SopelIdentifierMemory pop() does not cast
### Description

`SopelIdentifierMemory` automatically casts strings to `Identifier`s when accessing contents with `mem["foo"]`, but `pop()` doesn't seem to.

### Reproduction steps

```python
>>> from sopel.tools import Identifier, SopelIdentifierMemory
>>> mem = SopelIdentifierMemory()
>>> mem["Foo"] = "bar"
>>> mem["Foo"]
'bar'
>>> mem.pop("Foo")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'Foo'
>>> mem.pop(Identifier("Foo"))
'bar'
```

Note the capital F in "Foo" - hashing Identifier("foo") and "foo" seems to match, but not Identifier("Foo") and "Foo".

### Expected behavior

`pop()` performs the same cast as `mem["Foo"]` and works.

### Sopel version

271b306ac328ccea32da333787c00b739021bb39

### Installation method

`pip install`

### Python version

3.11.5


Discussion:
Follow-up note from IRC: The whole [dict API](https://docs.python.org/3/library/stdtypes.html#dict) should be checked when resolving this.
Went down the doc page testing each operation listed under `dict` and found the following:

- Working as expected
  - `d['key'] = 'foo'`
  - `list(d)`
  - `len(d)`
  - `d['key']`
  - `d['KeY']`
  - `del d['key']`
  - `'key' in d`
  - `'KeY' in d`
  - `iter(d)`
  - `SopelIdentifierMemory.fromkeys(('foo', 'bar', 'baz'), True)`
    Surprised me that this actually does create an object of the correct type, but the C implementation [appears to create a new object of the class it was called on](https://github.com/python/cpython/blob/7162c3a1e8707fdea2efcc70d2cec987eba9ea78/Objects/dictobject.c#L2285).
    - I wonder if this should be overridden with a pure Python implementation anyway, to guard against implementation differences between CPython and PyPy/IronPython/whatever else
  - `d.items()`/`d.keys()`/`d.values()`
    Created `iv`/`kv`/`vv` references to these views, made changes to `d`, and ensured the views all updated.
  - `d.pop('KeY')` (on draft PR's branch)
  - `d.pop('NotPresent', 'default')` (on draft PR's branch)
  - `d.popitem()`
  - `reversed(d)`
  - `d.setdefault('key', 'default')`
  - `d.update(another_identifiermemory)`
  - `d | another_identifiermemory` (new feature in py3.9)
  - `d |= another_identifiermemory` (new feature in py3.9)
- Wrong behavior or unexpected error
  - `del d['KeY']`
  - `d.clear()` (_appears_ to work, but the object becomes a regular `dict` and loses the `Identifier` magic)
  - `d.copy()`
    Made a copy `m` and added another item to it, verifying that the new item did not appear in the original `d`.
    However, the new object `m` is a `dict`, not a `SopelIdentifierMemory`.
  - `d.get('KeY')`
    Returns the default passed to `.get()`, not `key`'s value.
  - `d.pop('NotInDict')`
    On draft PR's branch, returns `None` but **should** raise `KeyError`.
  - `d.setdefault('KeY', 'default')`
    With `'key' in d`, returns `'default'` & adds `'KeY'` to `d` as a regular string, _not_ `Identifier`.
  - `d.update(a_plain_dict)`
    If `d` has `Identifier('thing1')` as a key, and `a_plain_dict` has `'ThinG1'` (a regular string), they will not be merged correctly and `d` will now also have a regular string key called `'ThinG1'`.
  - `d | a_plain_dict`
    Same problem as `d.update(a_plain_dict)`.
  - `d |= a_plain_dict`
    Same problem as `d.update(a_plain_dict)` and `d | a_plain_dict`.
  - `d == a_plain_dict`
    If `a_plain_dict` has the same key/value pairs but the key capitalization doesn't match `Identifier`'s canonical (lowercase) internal representation, the equality check returns `False`.
  - `result = SopelIdentifierMemory({'KeY': 'foo'})`
    `result` is of type `SopelIdentifierMemory`, but without the magic. Existing keys are not converted to `Identifier`, and `'key' in result` doesn't work as expected for that type.
    In fact, even `'KeY' in result` returns `False` for the example above, and `result['KeY'] raises `KeyError`. Even basic functionality of the resulting fake identifier memory is completely broken.

…I might not actually finish #2525 today, lol. That's a lot of stuff to figure out how to handle, then write unit tests for. 😅

Resolving patch:
diff --git a/sopel/tools/memories.py b/sopel/tools/memories.py
--- a/sopel/tools/memories.py
+++ b/sopel/tools/memories.py
@@ -8,10 +8,31 @@
 
 from collections import defaultdict
 import threading
-from typing import Optional
+from typing import Any, Optional, TYPE_CHECKING, Union
 
 from .identifiers import Identifier, IdentifierFactory
 
+if TYPE_CHECKING:
+    from collections.abc import Iterable, Mapping
+    from typing import Tuple
+
+    MemoryConstructorInput = Union[Mapping[str, Any], Iterable[Tuple[str, Any]]]
+
+
+class _NO_DEFAULT:
+    """Private class to help with overriding C methods like ``dict.pop()``.
+
+    Some Python standard library features are implemented in pure C, and can
+    have a ``null`` default value for certain parameters that is impossible to
+    emulate at the Python layer. This class is our workaround for that.
+
+    .. warning::
+
+        Plugin authors **SHOULD NOT** use this class. It is not part of Sopel's
+        public API.
+
+    """
+
 
 class SopelMemory(dict):
     """A simple thread-safe ``dict`` implementation.
@@ -20,6 +41,12 @@ class SopelMemory(dict):
     them at the same time from different threads, we use a blocking lock in
     ``__setitem__`` and ``__contains__``.
 
+    .. note::
+
+        Unlike the :class:`dict` on which they are based, ``SopelMemory`` and
+        its derivative types do not accept key-value pairs as keyword arguments
+        at construction time.
+
     .. versionadded:: 3.1
         As ``Willie.WillieMemory``
     .. versionchanged:: 4.0
@@ -57,6 +84,12 @@ def __contains__(self, key):
 class SopelMemoryWithDefault(defaultdict):
     """Same as SopelMemory, but subclasses from collections.defaultdict.
 
+    .. note::
+
+        Unlike the :class:`~collections.defaultdict` on which it is based,
+        ``SopelMemoryWithDefault`` does not accept key-value pairs as keyword
+        arguments at construction time.
+
     .. versionadded:: 4.3
         As ``WillieMemoryWithDefault``
     .. versionchanged:: 6.0
@@ -144,14 +177,47 @@ def __init__(
         *args,
         identifier_factory: IdentifierFactory = Identifier,
     ) -> None:
-        super().__init__(*args)
-        self.make_identifier: IdentifierFactory = identifier_factory
+        if len(args) > 1:
+            raise TypeError(
+                'SopelIdentifierMemory expected at most 1 argument, got {}'
+                .format(len(args))
+            )
+
+        self.make_identifier = identifier_factory
         """A factory to transform keys into identifiers."""
 
+        if len(args) == 1:
+            super().__init__(self._convert_keys(args[0]))
+        else:
+            super().__init__()
+
     def _make_key(self, key: Optional[str]) -> Optional[Identifier]:
-        if key is not None:
-            return self.make_identifier(key)
-        return None
+        if key is None:
+            return None
+        return self.make_identifier(key)
+
+    def _convert_keys(
+        self,
+        data: MemoryConstructorInput,
+    ) -> Iterable[tuple[Identifier, Any]]:
+        """Ensure input keys are converted to ``Identifer``.
+
+        :param data: the data passed to the memory at init or update
+        :return: a generator of key-value pairs with the keys converted
+                 to :class:`~.identifiers.Identifier`
+
+        This private method takes input of a mapping or an iterable of key-value
+        pairs and outputs a generator of key-value pairs ready for use in a new
+        or updated :class:`self` instance. It is designed to work with any of the
+        possible ways initial data can be passed to a :class:`dict`, except that
+        ``kwargs`` must be passed to this method as a dictionary.
+        """
+        # figure out what to generate from
+        if hasattr(data, 'items'):
+            data = data.items()
+
+        # return converted input data
+        return ((self.make_identifier(k), v) for k, v in data)
 
     def __getitem__(self, key: Optional[str]):
         return super().__getitem__(self._make_key(key))
@@ -161,3 +227,58 @@ def __contains__(self, key):
 
     def __setitem__(self, key: Optional[str], value):
         super().__setitem__(self._make_key(key), value)
+
+    def setdefault(self, key: str, default=None):
+        return super().setdefault(self._make_key(key), default)
+
+    def __delitem__(self, key: str):
+        super().__delitem__(self._make_key(key))
+
+    def copy(self):
+        return type(self)(self, identifier_factory=self.make_identifier)
+
+    def get(self, key: str, default=_NO_DEFAULT):
+        if default is _NO_DEFAULT:
+            return super().get(self._make_key(key))
+        return super().get(self._make_key(key), default)
+
+    def pop(self, key: str, default=_NO_DEFAULT):
+        if default is _NO_DEFAULT:
+            return super().pop(self._make_key(key))
+        return super().pop(self._make_key(key), default)
+
+    def update(self, maybe_mapping=tuple()):
+        super().update(self._convert_keys(maybe_mapping))
+
+    def __or__(self, other):
+        if not isinstance(other, dict):
+            return NotImplemented
+
+        # self on the left, so other's keys overwrite
+        new = self.copy()
+        new.update(other)
+        return new
+
+    def __ror__(self, other):
+        if not isinstance(other, dict):
+            return NotImplemented
+
+        # self on the right, so keep only new keys from other
+        new = self.copy()
+        new.update((k, v) for k, v in other.items() if k not in self)
+        return new
+
+    def __ior__(self, other):
+        if not isinstance(other, dict):
+            return NotImplemented
+        self.update(other)
+        return self
+
+    def __eq__(self, other):
+        if not isinstance(other, dict):
+            return NotImplemented
+        return super().__eq__(other)
+
+    def __ne__(self, other):
+        ret = self.__eq__(other)
+        return ret if ret is NotImplemented else not ret


Test patch:
diff --git a/test/tools/test_tools_memories.py b/test/tools/test_tools_memories.py
--- a/test/tools/test_tools_memories.py
+++ b/test/tools/test_tools_memories.py
@@ -1,9 +1,16 @@
 """Tests for Sopel Memory data-structures"""
 from __future__ import annotations
 
+import pytest
+
 from sopel.tools import identifiers, memories
 
 
+def test_sopel_default_memory_contains():
+    memory = memories.SopelMemoryWithDefault(list)
+    assert 'key' not in memory
+
+
 def test_sopel_identifier_memory_none():
     memory = memories.SopelIdentifierMemory()
     assert None not in memory
@@ -58,3 +65,451 @@ def test_sopel_identifier_memory_channel_str():
     assert memory[channel] == test_value
     assert memory['#adminchannel'] == test_value
     assert memory['#AdminChannel'] == test_value
+
+
+def test_sopel_identifier_memory_setdefault():
+    memory = memories.SopelIdentifierMemory()
+    assert 'foo' not in memory
+
+    memory.setdefault('Foo')
+    assert len(memory) == 1
+    assert 'foo' in memory
+    assert 'fOO' in memory
+    assert memory['fOo'] is None
+
+    memory.setdefault('bAr', 'DEFAULT')
+    assert len(memory) == 2
+    assert 'FoO' in memory
+    assert 'BaR' in memory
+    assert memory['Bar'] == 'DEFAULT'
+
+    assert memory.setdefault('baR', 'different') == 'DEFAULT'
+    assert len(memory) == 2
+
+
+def test_sopel_identifier_memory_del_key():
+    memory = memories.SopelIdentifierMemory()
+    memory['KeY'] = True
+    assert 'KeY' in memory
+    del memory['KeY']
+    assert 'KeY' not in memory
+
+    memory['kEy'] = True
+    assert 'KeY' in memory
+    del memory['KeY']
+    assert 'KeY' not in memory
+
+
+def test_sopel_identifier_memory_copy():
+    memory = memories.SopelIdentifierMemory()
+    memory['SomeCamelCase'] = True
+    memory['loweronly'] = False
+    assert len(memory) == 2
+    assert isinstance(memory, memories.SopelIdentifierMemory)
+
+    copied = memory.copy()
+    assert len(copied) == 2
+    assert 'SomeCamelCase' in copied
+    assert 'loweronly' in copied
+    assert isinstance(memory, memories.SopelIdentifierMemory)
+
+    # be absolutely sure it's a new object
+    copied['newOnly'] = True
+    assert 'NewOnly' in copied
+    assert 'NewOnly' not in memory
+    del copied['LowerOnly']
+    assert 'LowerOnly' not in copied
+    assert 'LowerOnly' in memory
+
+
+def test_sopel_identifier_memory_get():
+    memory = memories.SopelIdentifierMemory()
+    memory['SomeCamelCase'] = True
+    memory['loweronly'] = False
+    assert len(memory) == 2
+
+    # verify key-exists behavior w/implicit default
+    assert memory.get('somecamelcase') == memory['somecamelcase']
+    assert memory.get('LowerOnly') == memory['LowerOnly']
+    assert len(memory) == 2
+
+    # verify key-exists behavior w/explicit default of None
+    assert memory.get('somecamelcase', None) == memory['somecamelcase']
+    assert memory.get('LowerOnly', None) == memory['LowerOnly']
+    assert len(memory) == 2
+
+    # verify key-exists behavior w/explicit "real" default value
+    assert memory.get('somecamelcase', 'DEFAULT') == memory['somecamelcase']
+    assert memory.get('LowerOnly', 'DEFAULT') == memory['LowerOnly']
+    assert len(memory) == 2
+
+    # verify key-missing behavior w/implicit default
+    assert 'missing_key' not in memory
+    assert memory.get('missing_key') is None
+    assert len(memory) == 2
+
+    # verify key-missing behavior w/explicit default of None
+    assert 'missing_key' not in memory
+    assert memory.get('missing_key', None) is None
+    assert len(memory) == 2
+
+    # verify key-missing behavior w/explicit "real" default value
+    assert 'missing_key' not in memory
+    assert memory.get('missing_key', 'DEFAULT') == 'DEFAULT'
+    assert len(memory) == 2
+
+
+def test_sopel_identifier_memory_pop():
+    memory = memories.SopelIdentifierMemory()
+    memory['SomeCamelCase'] = True
+    assert len(memory) == 1
+
+    # verify SopelIdentifierMemory.pop('key') behavior
+    pop_me = memory.copy()
+    assert len(pop_me) == 1
+    assert pop_me.pop('someCAMELcase') is True
+    assert len(memory) == 1  # sanity check
+    assert len(pop_me) == 0
+
+    # verify SopelIdentifierMemory.pop('key', None) behavior
+    pop_me = memory.copy()
+    assert len(pop_me) == 1
+    assert pop_me.pop('someCAMELcase', None) is True
+    assert len(pop_me) == 0
+
+    # verify SopelIdentifierMemory.pop('key', 'default_value') behavior
+    pop_me = memory.copy()
+    assert len(pop_me) == 1
+    assert pop_me.pop('someCAMELcase', 'DEFAULT') is True
+    assert len(pop_me) == 0
+
+    # verify SopelIdentifierMemory.pop('missing_key') behavior
+    pop_me = memory.copy()
+    assert len(pop_me) == 1
+    assert 'missing_key' not in pop_me
+    with pytest.raises(KeyError):
+        pop_me.pop('missing_key')
+    assert len(pop_me) == 1
+
+    # verify SopelIdentifierMemory.pop('missing_key', None) behavior
+    assert 'missing_key' not in pop_me
+    assert pop_me.pop('missing_key', None) is None
+    assert len(pop_me) == 1
+
+    # verify SopelIdentifierMemory.pop('missing_key', 'default_value') behavior
+    assert 'missing_key' not in pop_me
+    assert pop_me.pop('missing_key', 'DEFAULT') == 'DEFAULT'
+    assert len(pop_me) == 1
+
+
+def test_sopel_identifier_memory_from_dict():
+    dictionary = {'CamelCaseKey': True, 'lowercasekey': False}
+
+    memory = memories.SopelIdentifierMemory(dictionary)
+    assert 'CamelCaseKey' in memory
+    assert 'camelcasekey' in memory
+    assert memory['cameLcasEkeY'] == dictionary['CamelCaseKey']
+    assert 'lowercasekey' in memory
+    assert 'LowerCaseKey' in memory
+    assert memory['LoWeRcAsEkEy'] == dictionary['lowercasekey']
+
+
+def test_sopel_identifier_memory_from_identifier_memory():
+    original_memory = memories.SopelIdentifierMemory(
+        {'CamelCaseKey': True, 'lowercasekey': False}
+    )
+    assert 'CamelCaseKey' in original_memory
+    assert 'camelcasekey' in original_memory
+    assert 'lowercasekey' in original_memory
+    assert 'LowerCaseKey' in original_memory
+
+    memory = memories.SopelIdentifierMemory(original_memory)
+    assert 'CamelCaseKey' in memory
+    assert 'camelcasekey' in memory
+    assert memory['cameLcasEkeY'] is True
+    assert 'lowercasekey' in memory
+    assert 'LowerCaseKey' in memory
+    assert memory['LoWeRcAsEkEy'] is False
+
+    # be sure we really have a new object
+    memory['newOnly'] = True
+    assert 'NewOnly' in memory
+    assert 'NewOnly' not in original_memory
+    del memory['cameLcasEkeY']
+    assert 'cameLcasEkeY' not in memory
+    assert 'cameLcasEkeY' in original_memory
+
+
+def test_sopel_identifier_memory_from_list():
+    pairs_list = [('CamelCaseKey', True), ('lowercasekey', False)]
+    memory = memories.SopelIdentifierMemory(pairs_list)
+
+    assert 'CamelCaseKey' in memory
+    assert 'camelcasekey' in memory
+    assert memory['cameLcasEkeY'] is True
+    assert 'lowercasekey' in memory
+    assert 'LowerCaseKey' in memory
+    assert memory['LoWeRcAsEkEy'] is False
+
+
+def test_sopel_identifier_memory_from_tuple():
+    pairs_tuple = (('CamelCaseKey', True), ('lowercasekey', False))
+    memory = memories.SopelIdentifierMemory(pairs_tuple)
+    assert 'CamelCaseKey' in memory
+    assert 'camelcasekey' in memory
+    assert memory['cameLcasEkeY'] is True
+    assert 'lowercasekey' in memory
+    assert 'LowerCaseKey' in memory
+    assert memory['LoWeRcAsEkEy'] is False
+
+
+def test_sopel_identifier_memory_from_kwargs():
+    # This is unsupported behavior, by design.
+    with pytest.raises(TypeError):
+        memories.SopelIdentifierMemory(CamelCaseKey=True, lowercasekey=False)
+
+
+def test_sopel_identifier_memory_clear():
+    memory = memories.SopelIdentifierMemory(
+        {'FooBar': 'foobar', 'BarFoo': 'barfoo'}
+    )
+    assert isinstance(memory, memories.SopelIdentifierMemory)
+    assert len(memory) == 2
+    assert 'FooBar' in memory
+    assert memory['FooBar'] == 'foobar'
+    assert 'BarFoo' in memory
+    assert memory['BarFoo'] == 'barfoo'
+
+    memory.clear()
+    assert isinstance(memory, memories.SopelIdentifierMemory)
+    assert len(memory) == 0
+    assert 'FooBar' not in memory
+    assert 'BarFoo' not in memory
+
+    memory['spameggs'] = 'spameggs'
+    assert len(memory) == 1
+    assert 'SpamEggs' in memory
+    assert memory['SpamEggs'] == 'spameggs'
+    assert memory['spaMeggS'] == 'spameggs'
+    assert memory['spameggs'] == memory['sPAmeGGs']
+
+
+def test_sopel_identifier_memory_update():
+    memory = memories.SopelIdentifierMemory({
+        'FromIdentifierMemory': True,
+    })
+    tuple_ = (('FromTuplePairs', True), ('FromIdentifierMemory', False))
+    list_ = [('FromTuplePairs', False), ('FromListPairs', True)]
+    dict_ = {'FromDict': True, 'FromListPairs': False}
+    set_ = set((('FromDict', False), ('FromSet', True)))
+
+    assert len(memory) == 1
+    assert memory['froMidentifieRmemorY'] is True
+    assert 'froMtuplEpairS' not in memory
+    assert 'froMlisTpairS' not in memory
+    assert 'froMdicT' not in memory
+    assert 'froMseT' not in memory
+
+    memory.update(tuple_)
+    assert len(memory) == 2
+    assert memory['froMidentifieRmemorY'] is False
+    assert memory['froMtuplEpairS'] is True
+    assert 'froMlisTpairS' not in memory
+    assert 'froMdicT' not in memory
+    assert 'froMseT' not in memory
+
+    memory.update(list_)
+    assert len(memory) == 3
+    assert memory['froMidentifieRmemorY'] is False
+    assert memory['froMtuplEpairS'] is False
+    assert memory['froMlisTpairS'] is True
+    assert 'froMdicT' not in memory
+    assert 'froMseT' not in memory
+
+    memory.update(dict_)
+    assert len(memory) == 4
+    assert memory['froMidentifieRmemorY'] is False
+    assert memory['froMtuplEpairS'] is False
+    assert memory['froMlisTpairS'] is False
+    assert memory['froMdicT'] is True
+    assert 'froMseT' not in memory
+
+    memory.update(set_)
+    assert len(memory) == 5
+    assert memory['froMidentifieRmemorY'] is False
+    assert memory['froMtuplEpairS'] is False
+    assert memory['froMlisTpairS'] is False
+    assert memory['froMdicT'] is False
+    assert memory['froMseT'] is True
+
+
+def test_sopel_identifier_memory_or_op():
+    memory = memories.SopelIdentifierMemory({
+        'FromMemory': True,
+        'FromOther': False,
+    })
+    assert len(memory) == 2
+    assert memory['froMmemorY'] is True
+    assert memory['froMotheR'] is False
+
+    other_memory = memories.SopelIdentifierMemory({
+        'FromMemory': False,
+        'FromOther': True,
+    })
+    assert len(other_memory) == 2
+    assert 'FromOther' in other_memory
+    assert other_memory['FromOther'] is True
+    assert 'fromother' in other_memory
+
+    result = memory | other_memory
+    assert len(result) == 2
+    assert result['fROmMemorY'] is False
+    assert result['fROmoTHEr'] is True
+
+    result = other_memory | memory
+    assert len(result) == 2
+    assert result['FroMMemorY'] is True
+    assert result['FroMOtheR'] is False
+
+
+def test_sopel_identifier_memory_or_op_dict():
+    memory = memories.SopelIdentifierMemory({'FromMemory': True, 'FromDict': False})
+    assert len(memory) == 2
+    assert memory['froMmemorY'] is True
+    assert memory['froMdicT'] is False
+
+    dictionary = {'FromMemory': False, 'FromDict': True, 'AlsoFromDict': True}
+    assert len(dictionary) == 3
+    assert dictionary['FromDict'] is True
+    assert dictionary['FromMemory'] is False
+    assert dictionary['AlsoFromDict'] is True
+    assert 'alsofromdict' not in dictionary
+
+    # __or__
+    result = memory | dictionary
+    assert len(result) == 3
+    assert result['fROmMemorY'] is False
+    assert result['fROmDicT'] is True
+    assert result['alsOfroMdicT'] is True
+
+    # __ror__
+    result = dictionary | memory
+    assert len(result) == 3
+    assert result['fROmMemorY'] is True
+    assert result['fROmDicT'] is False
+    assert result['alsOfroMdicT'] is True
+
+
+def test_sopel_identifier_memory_or_op_other():
+    memory = memories.SopelIdentifierMemory()
+
+    with pytest.raises(TypeError):
+        # __or__
+        memory | 5
+    with pytest.raises(TypeError):
+        # __ror__
+        'str' | memory
+
+
+def test_sopel_identifier_memory_ior_op():
+    memory = memories.SopelIdentifierMemory({'FromMemory': True})
+    assert len(memory) == 1
+    assert memory['froMmemorY'] is True
+
+    other_memory = memories.SopelIdentifierMemory({
+        'FromMemory': False,
+        'FromOther': True,
+    })
+    assert len(other_memory) == 2
+    assert 'FromOther' in other_memory
+    assert other_memory['froMotheR'] is True
+
+    memory |= other_memory
+    assert len(memory) == 2
+    assert memory['fROmMemorY'] is False
+    assert memory['fROmoTHEr'] is True
+
+
+def test_sopel_identifier_memory_ior_op_dict():
+    memory = memories.SopelIdentifierMemory({'FromMemory': True})
+    assert len(memory) == 1
+    assert memory['froMmemorY'] is True
+
+    dictionary = {'FromDict': True}
+    assert len(dictionary) == 1
+    assert 'FromDict' in dictionary
+    assert dictionary['FromDict'] is True
+    assert 'fromdict' not in dictionary
+
+    memory |= dictionary
+    assert len(memory) == 2
+    assert memory['fROmMemorY'] is True
+    assert memory['fROmDicT'] is True
+
+
+def test_sopel_identifier_memory_ior_op_other():
+    memory = memories.SopelIdentifierMemory()
+
+    with pytest.raises(TypeError):
+        memory |= 5
+    with pytest.raises(TypeError):
+        memory |= 'str'
+
+
+def test_sopel_identifier_memory_eq():
+    """Test equality checks between two `SopelIdentifierMemory` instances."""
+    memory = memories.SopelIdentifierMemory({'Foo': 'bar', 'Baz': 'Luhrmann'})
+    other_memory = memories.SopelIdentifierMemory((('Foo', 'bar'), ('Baz', 'Luhrmann')))
+
+    assert memory == other_memory
+    assert other_memory == memory  # cover our bases vis-a-vis operand precedence
+
+    memory = memories.SopelIdentifierMemory({'fOO': 'bar', 'bAZ': 'Luhrmann'})
+
+    assert memory == other_memory
+    assert other_memory == memory  # cover our bases vis-a-vis operand precedence
+
+
+def test_sopel_identifier_memory_eq_dict_id():
+    """Test ``SopelIdentifierMemory`` comparison to ``dict[Identifier, Any]``.
+
+    These can be equivalent, just like two ``dict`` objects with identical
+    ``Identifier`` keys can be.
+    """
+    memory = memories.SopelIdentifierMemory({'Foo': 'bar', 'Baz': 'Luhrmann'})
+    dictionary = dict(memory)
+
+    assert dictionary == memory
+    assert memory == dictionary  # cover our bases vis-a-vis operand precedence
+
+
+def test_sopel_identifier_memory_eq_dict_str():
+    """Test ``SopelIdentifierMemory`` comparison to ``dict[str, Any]``.
+
+    These are intentionally not considered equivalent.
+    """
+    dictionary = {'Foo': 'bar', 'Baz': 'Luhrmann'}
+    memory = memories.SopelIdentifierMemory(dictionary)
+
+    assert dictionary != memory
+    assert memory != dictionary  # cover our bases vis-a-vis operand precedence
+
+    memory = memories.SopelIdentifierMemory((('Spam', 'eggs'), ('GreenEggs', 'ham')))
+
+    assert dictionary != memory
+    assert memory != dictionary  # cover our bases vis-a-vis operand precedence
+
+
+def test_sopel_identifier_memory_eq_other():
+    """Test ``SopelIdentifierMemory`` comparison to non-dict types.
+
+    Never equivalent (``NotImplemented``).
+    """
+    memory = memories.SopelIdentifierMemory({'Foo': 'bar', 'Baz': 'Luhrmann'})
+
+    assert memory != 'string'
+    assert memory != 5
+    assert memory != ('Foo', 'Bar')
+
+    # operator precedence ass-covering
+    assert 'string' != memory