Skip to content

Commit fc48829

Browse files
committed
Merge branch 'main' into fix/block-hash
2 parents 22bac9a + f4b8cd3 commit fc48829

2 files changed

Lines changed: 247 additions & 3 deletions

File tree

‎bibtexparser/entrypoint.py‎

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import codecs
22
import warnings
33
from collections.abc import Iterable
4+
from copy import deepcopy
45
from typing import Optional
56
from typing import TextIO
67

78
from .library import Library
9+
from .middlewares.enclosing import REMOVED_ENCLOSING_KEY
810
from .middlewares.middleware import Middleware
911
from .middlewares.parsestack import default_parse_stack
1012
from .middlewares.parsestack import default_unparse_stack
13+
from .model import Block
14+
from .model import String
1115
from .splitter import Splitter
1216
from .writer import BibtexFormat
1317
from .writer import write
1418

19+
#: Marks a seeded copy of a pre-existing `@string`, dropped before merging back.
20+
_PREEXISTING_STRING_KEY = "bibtexparser_preexisting_string"
21+
1522

1623
def _build_parse_stack(
1724
parse_stack: Iterable[Middleware] | None,
@@ -137,20 +144,97 @@ def parse_string(
137144
(ignored if a not-``None`` parse_stack is passed).
138145
139146
:param library:
140-
Library to add entries to. If ``None`` (default), a new library will be created.
147+
Library to add the newly parsed blocks to.
148+
If ``None`` (default), a new library is created and returned.
149+
If a library is passed, it is returned (mutated) and:
150+
151+
- the parse stack is applied **only** to the newly parsed blocks;
152+
blocks already contained in the passed library are left untouched
153+
(they were already transformed when they were parsed);
154+
- ``@string`` blocks already contained in the passed library are visible
155+
to the parse stack, i.e. string references in ``bibtex_str`` resolve
156+
against them (unless ``bibtex_str`` redefines the same key);
157+
- keys defined both in the passed library and in ``bibtex_str``
158+
do not raise, but yield ``DuplicateBlockKeyBlock`` instances
159+
(see ``library.failed_blocks``), just like duplicates within a single string.
141160
142161
:return: Library: Parsed BibTeX database
143162
"""
144163
splitter = Splitter(bibstr=bibtex_str)
145-
library = splitter.split(library=library)
164+
parsed = splitter.split()
165+
166+
_seed_preexisting_strings(parsed, library)
146167

147168
middleware: Middleware
148169
for middleware in _build_parse_stack(parse_stack, append_middleware):
149-
library = middleware.transform(library=library)
170+
parsed = middleware.transform(library=parsed)
150171

172+
if library is None:
173+
return parsed
174+
175+
new_blocks = [b for b in parsed.blocks if not _is_seeded_string(b)]
176+
library.add(new_blocks, fail_on_duplicate_key=False)
151177
return library
152178

153179

180+
def _is_seeded_string(block: Block) -> bool:
181+
"""True for blocks seeded by `_seed_preexisting_strings` (and their transformations)."""
182+
return bool(block.get_parser_metadata(_PREEXISTING_STRING_KEY))
183+
184+
185+
def _restore_enclosing(string: String) -> None:
186+
"""Make sure the value of an already-parsed string is enclosed again.
187+
188+
The parse stack expects freshly split (i.e. still enclosed) values.
189+
Feeding it an already-stripped value would make that value be treated
190+
as an unenclosed literal (a string reference), which does not round-trip
191+
to valid bibtex.
192+
"""
193+
enclosing = string.parser_metadata.pop(REMOVED_ENCLOSING_KEY, None)
194+
if string.enclosing == "no-enclosing" or enclosing == "no-enclosing":
195+
return
196+
value = string.value
197+
if not isinstance(value, str):
198+
return
199+
if enclosing is None and (
200+
(value.startswith("{") and value.endswith("}"))
201+
or (value.startswith('"') and value.endswith('"'))
202+
):
203+
return
204+
string.value = f'"{value}"' if enclosing == '"' else f"{{{value}}}"
205+
206+
207+
def _seed_preexisting_strings(parsed: Library, library: Library | None) -> list[String]:
208+
"""Make the ``@string`` blocks of an existing library visible to the parse stack.
209+
210+
Copies (never the originals, which must not be transformed again) of the
211+
strings of ``library`` are added to ``parsed``, unless the newly parsed
212+
content redefines the same key. The copies are tagged so that they can be
213+
dropped again before merging the parsed blocks back into ``library``.
214+
215+
:param parsed: The freshly split library, modified in place.
216+
:param library: The pre-existing library, or ``None``.
217+
:return: The seeded (tagged) string copies.
218+
"""
219+
if library is None:
220+
return []
221+
222+
# Bibtex string keys are case-insensitive, hence compare in lower case.
223+
redefined = {key.lower() for key in parsed.strings_dict}
224+
seeds = []
225+
for key, string in library.strings_dict.items():
226+
if key.lower() in redefined:
227+
continue
228+
seed = deepcopy(string)
229+
seed.set_parser_metadata(_PREEXISTING_STRING_KEY, True)
230+
_restore_enclosing(seed)
231+
seeds.append(seed)
232+
233+
if seeds:
234+
parsed.add(seeds, fail_on_duplicate_key=False)
235+
return seeds
236+
237+
154238
def parse_file(
155239
path: str,
156240
parse_stack: Iterable[Middleware] | None = None,

‎tests/test_entrypoint.py‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77
import pytest
88

99
from bibtexparser import parse_file
10+
from bibtexparser import parse_string
1011
from bibtexparser import write_file
1112
from bibtexparser import write_string
1213
from bibtexparser.library import Library
14+
from bibtexparser.model import DuplicateBlockKeyBlock
1315
from bibtexparser.model import Entry
1416
from bibtexparser.model import Field
17+
from bibtexparser.model import String
1518

1619

1720
def test_gbk():
@@ -241,3 +244,160 @@ def test_write_string_unexpected_keyword_argument_raises_error():
241244
write_string(library, unknown_param="value")
242245
assert "unexpected keyword arguments" in str(excinfo.value)
243246
assert "unknown_param" in str(excinfo.value)
247+
248+
249+
FIRST_BIBTEX = """@string{me = "My Name"}
250+
251+
@article{first,
252+
title = {Hello World},
253+
author = me,
254+
year = 2023
255+
}"""
256+
257+
258+
def test_parse_string_into_existing_library_returns_the_passed_library():
259+
"""The passed library is mutated and returned, no new instance is created."""
260+
library = parse_string(FIRST_BIBTEX)
261+
returned = parse_string("@article{second, title = {Second}}", library=library)
262+
assert returned is library
263+
264+
265+
def test_parse_string_into_existing_library_keeps_previous_blocks_untouched():
266+
"""Blocks already in the library must not be transformed a second time."""
267+
library = parse_string(FIRST_BIBTEX)
268+
first_entry = library.entries_dict["first"]
269+
first_string = library.strings_dict["me"]
270+
271+
parse_string("@article{second, title = {Second}}", library=library)
272+
273+
assert library.entries_dict["first"] is first_entry
274+
assert library.strings_dict["me"] is first_string
275+
assert first_entry["title"] == "Hello World"
276+
assert first_entry["author"] == "My Name"
277+
assert first_entry["year"] == "2023"
278+
assert first_string.value == "My Name"
279+
assert all(field.enclosing is None for field in first_entry.fields)
280+
assert first_string.enclosing is None
281+
282+
283+
def test_parse_string_into_existing_library_does_not_warn():
284+
"""The parse stack must not be re-applied, hence no enclosing-order warning."""
285+
library = parse_string(FIRST_BIBTEX)
286+
with warnings.catch_warnings(record=True) as w:
287+
warnings.simplefilter("always")
288+
parse_string("@article{second, title = {Second}, author = me}", library=library)
289+
assert [str(warning.message) for warning in w] == []
290+
291+
292+
def test_parse_string_into_existing_library_roundtrips():
293+
"""The resulting library must still serialize to valid (re-parsable) bibtex."""
294+
library = parse_string(FIRST_BIBTEX)
295+
parse_string("@article{second, title = {Second}, author = me}", library=library)
296+
297+
written = write_string(library)
298+
assert "title = {Hello World}" in written
299+
assert "author = {My Name}" in written
300+
assert "@string{me = {My Name}}" in written
301+
302+
reparsed = parse_string(written)
303+
assert reparsed.failed_blocks == []
304+
assert reparsed.entries_dict["first"]["title"] == "Hello World"
305+
assert reparsed.entries_dict["first"]["author"] == "My Name"
306+
assert reparsed.entries_dict["second"]["author"] == "My Name"
307+
assert reparsed.strings_dict["me"].value == "My Name"
308+
309+
310+
def test_parse_string_into_existing_library_is_equivalent_to_parsing_at_once():
311+
"""Parsing in two steps must yield the same bibtex as parsing everything at once."""
312+
second_bibtex = "@article{second, title = {Second}, author = me}"
313+
314+
stepwise = parse_string(FIRST_BIBTEX)
315+
parse_string(second_bibtex, library=stepwise)
316+
at_once = parse_string(FIRST_BIBTEX + "\n\n" + second_bibtex)
317+
318+
assert write_string(stepwise) == write_string(at_once)
319+
320+
321+
def test_parse_string_into_existing_library_resolves_previously_defined_strings():
322+
"""String references in the new content resolve against earlier @string blocks."""
323+
library = parse_string(FIRST_BIBTEX)
324+
parse_string("@article{second, author = me}", library=library)
325+
326+
assert library.entries_dict["second"]["author"] == "My Name"
327+
assert len(library.strings) == 1
328+
assert [type(block) for block in library.blocks].count(String) == 1
329+
330+
331+
def test_parse_string_into_existing_library_resolves_strings_case_insensitively():
332+
"""As within a single parse, string references are case-insensitive."""
333+
library = parse_string(FIRST_BIBTEX)
334+
parse_string("@article{second, author = ME}", library=library)
335+
assert library.entries_dict["second"]["author"] == "My Name"
336+
337+
338+
def test_parse_string_repeatedly_into_existing_library():
339+
"""More than two consecutive calls keep working (no accumulating corruption)."""
340+
library = parse_string(FIRST_BIBTEX)
341+
for key in ("second", "third", "fourth"):
342+
parse_string(f"@article{{{key}, author = me}}", library=library)
343+
344+
assert len(library.entries) == 4
345+
assert library.failed_blocks == []
346+
assert all(entry["author"] == "My Name" for entry in library.entries)
347+
assert write_string(library).count("author = {My Name}") == 4
348+
349+
350+
def test_parse_string_into_existing_library_duplicate_entry_key():
351+
"""A duplicate entry key across two calls yields a DuplicateBlockKeyBlock."""
352+
library = parse_string("@article{duplicate, title = {First}}")
353+
parse_string("@article{duplicate, title = {Second}}", library=library)
354+
355+
assert len(library.failed_blocks) == 1
356+
failed = library.failed_blocks[0]
357+
assert isinstance(failed, DuplicateBlockKeyBlock)
358+
assert failed.key == "duplicate"
359+
assert library.entries_dict["duplicate"]["title"] == "First"
360+
361+
362+
def test_parse_string_into_existing_library_redefined_string_key():
363+
"""A @string redefined by the new content becomes a DuplicateBlockKeyBlock,
364+
but is used to resolve references within the newly parsed content."""
365+
library = parse_string('@string{me = "Old"}\n@article{first, author = me}')
366+
parse_string('@string{me = "New"}\n@article{second, author = me}', library=library)
367+
368+
assert library.strings_dict["me"].value == "Old"
369+
assert len(library.failed_blocks) == 1
370+
assert isinstance(library.failed_blocks[0], DuplicateBlockKeyBlock)
371+
assert library.failed_blocks[0].key == "me"
372+
373+
assert library.entries_dict["first"]["author"] == "Old"
374+
assert library.entries_dict["second"]["author"] == "New"
375+
376+
377+
def test_parse_string_into_manually_created_library():
378+
"""Strings of a hand-built (never parsed) library are usable as references."""
379+
library = Library(
380+
[
381+
Entry("article", "manual", [Field("title", "Manual")]),
382+
String("manual_string", "Some Value"),
383+
]
384+
)
385+
parse_string("@article{parsed, author = manual_string}", library=library)
386+
387+
assert library.entries_dict["parsed"]["author"] == "Some Value"
388+
assert library.entries_dict["manual"]["title"] == "Manual"
389+
assert "author = {Some Value}" in write_string(library)
390+
391+
392+
def test_parse_string_into_existing_library_keeps_block_order():
393+
"""Newly parsed blocks are appended after the pre-existing ones."""
394+
library = parse_string("% first comment\n@article{first, title = {First}}")
395+
parse_string("% second comment\n@article{second, title = {Second}}", library=library)
396+
397+
assert [block.__class__.__name__ for block in library.blocks] == [
398+
"ImplicitComment",
399+
"Entry",
400+
"ImplicitComment",
401+
"Entry",
402+
]
403+
assert [entry.key for entry in library.entries] == ["first", "second"]

0 commit comments

Comments
 (0)