Skip to content

Commit fb2eb27

Browse files
MiWeissclaude
andauthored
✨ Make blocks and fields hashable (sciunto-org#563)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 99485fd commit fb2eb27

4 files changed

Lines changed: 93 additions & 2 deletions

File tree

‎bibtexparser/library.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,30 @@ def _find_duplicate_keys(self, blocks: List[Block]) -> List[str]:
8080
seen_string_keys.add(block.key)
8181
return duplicate_keys
8282

83+
def _block_index(self, block: Block) -> int:
84+
"""Index of a block in the library, preferring identity over equality.
85+
86+
:param block: Block to look up.
87+
:raises ValueError: If block is not in library."""
88+
for i, b in enumerate(self._blocks):
89+
if b is block:
90+
return i
91+
# No identity match; fall back to equality (raises ValueError if not found).
92+
return self._blocks.index(block)
93+
8394
def remove(self, blocks: Union[List[Block], Block]):
8495
"""Remove blocks from library.
8596
97+
If equal duplicate blocks exist in the library, the exact (identical)
98+
instance is removed, if present; otherwise the first equal block.
99+
86100
:param blocks: Block or list of blocks to remove.
87101
:raises ValueError: If block is not in library."""
88102
if isinstance(blocks, Block):
89103
blocks = [blocks]
90104

91105
for block in blocks:
92-
self._blocks.remove(block)
106+
del self._blocks[self._block_index(block)]
93107
if isinstance(block, Entry):
94108
del self._entries_by_key[block.key]
95109
elif isinstance(block, String):
@@ -98,14 +112,17 @@ def remove(self, blocks: Union[List[Block], Block]):
98112
def replace(self, old_block: Block, new_block: Block, fail_on_duplicate_key: bool = True):
99113
"""Replace a block with another block, at the same position.
100114
115+
If equal duplicate blocks exist in the library, the exact (identical)
116+
instance is replaced, if present; otherwise the first equal block.
117+
101118
:param old_block: Block to replace.
102119
:param new_block: Block to replace with.
103120
:param fail_on_duplicate_key: If False, adds a DuplicateKeyBlock if
104121
a block with new_block.key (other than old_block) already exists.
105122
:raises ValueError: If old_block is not in library or if fail_on_duplicate_key is True
106123
and a block with new_block.key (other than old_block) already exists."""
107124
try:
108-
index = self._blocks.index(old_block)
125+
index = self._block_index(old_block)
109126
self.remove(old_block)
110127
except ValueError:
111128
raise ValueError("Block to replace is not in library.")

‎bibtexparser/model.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ def __eq__(self, other: object) -> bool:
8686
and self.__dict__ == other.__dict__
8787
)
8888

89+
def __hash__(self) -> int:
90+
# Hash on a stable subset of the attributes compared in `__eq__`
91+
# (equal blocks thus have equal hashes). Mutable or potentially
92+
# unhashable attributes (e.g. fields, parser_metadata) are excluded.
93+
return hash((type(self), self._start_line_in_file, self._raw))
94+
8995

9096
class String(Block):
9197
"""Bibtex Blocks of the ``@string`` type, e.g. ``@string{me = "My Name"}``."""
@@ -289,6 +295,12 @@ def __eq__(self, other: object) -> bool:
289295
and self.__dict__ == other.__dict__
290296
)
291297

298+
def __hash__(self) -> int:
299+
# Hash on a stable subset of the attributes compared in `__eq__`
300+
# (equal fields thus have equal hashes). The value is excluded as it
301+
# may be mutable or unhashable (e.g. a list after middleware was applied).
302+
return hash((type(self), self._start_line, self._key))
303+
292304
def __str__(self) -> str:
293305
return f"Field (line: {self.start_line}, key: `{self.key}`): `{self.value}`"
294306

‎tests/test_library.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from bibtexparser import Library
44
from bibtexparser.model import Entry
55
from bibtexparser.model import Field
6+
from bibtexparser.model import ImplicitComment
67

78

89
def get_dummy_entry():
@@ -94,3 +95,33 @@ def test_constructor_fails_on_duplicate_by_default():
9495
library = Library(blocks=[get_dummy_entry(), get_dummy_entry()], fail_on_duplicate_key=False)
9596
assert len(library.blocks) == 2
9697
assert len(library.failed_blocks) == 1
98+
99+
100+
def test_remove_prefers_identical_instance_over_equal_block():
101+
"""With two equal blocks, remove() must remove the passed instance. See issue 537."""
102+
first_comment = ImplicitComment("#same")
103+
second_comment = ImplicitComment("#same")
104+
assert first_comment == second_comment
105+
106+
library = Library()
107+
library.add([first_comment, second_comment])
108+
109+
library.remove(second_comment)
110+
assert len(library.blocks) == 1
111+
assert library.blocks[0] is first_comment
112+
113+
114+
def test_replace_prefers_identical_instance_over_equal_block():
115+
"""With two equal blocks, replace() must replace the passed instance. See issue 537."""
116+
first_comment = ImplicitComment("#same")
117+
second_comment = ImplicitComment("#same")
118+
assert first_comment == second_comment
119+
120+
library = Library()
121+
library.add([first_comment, second_comment])
122+
123+
new_comment = ImplicitComment("#new")
124+
library.replace(second_comment, new_comment)
125+
assert len(library.blocks) == 2
126+
assert library.blocks[0] is first_comment
127+
assert library.blocks[1] is new_comment

‎tests/test_model.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,37 @@ def test_string_enclosing_validation():
392392
String("myKey", "myValue", 1, "raw", enclosing="invalid")
393393

394394

395+
def test_entry_hash():
396+
# Equal entries have equal hashes
397+
entry_1 = Entry("article", "key", [Field("field", "value", 1)], 1, "raw")
398+
entry_2 = Entry("article", "key", [Field("field", "value", 1)], 1, "raw")
399+
assert hash(entry_1) == hash(entry_2)
400+
# Entries are usable in sets and dicts
401+
assert len({entry_1, deepcopy(entry_1)}) == 1
402+
assert {entry_1: "value"}[entry_2] == "value"
403+
404+
405+
def test_string_hash():
406+
# Equal strings have equal hashes
407+
string_1 = String("key", "value", 1, "raw")
408+
string_2 = String("key", "value", 1, "raw")
409+
assert hash(string_1) == hash(string_2)
410+
# Strings are usable in sets and dicts
411+
assert len({string_1, deepcopy(string_1)}) == 1
412+
assert {string_1: "value"}[string_2] == "value"
413+
414+
415+
def test_field_hash():
416+
# Equal fields have equal hashes
417+
field_1 = Field("field", "value", 1)
418+
field_2 = Field("field", "value", 1)
419+
assert hash(field_1) == hash(field_2)
420+
# Fields are usable in sets and dicts, even with unhashable values
421+
field_1.value = ["some", "unhashable", "value"]
422+
assert len({field_1, deepcopy(field_1)}) == 1
423+
assert {field_1: "value"}[deepcopy(field_1)] == "value"
424+
425+
395426
def test_entry_fields_shorthand():
396427
entry = Entry(
397428
entry_type="article",

0 commit comments

Comments
 (0)