Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 87 additions & 3 deletions bibtexparser/entrypoint.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import codecs
import warnings
from collections.abc import Iterable
from copy import deepcopy
from typing import Optional
from typing import TextIO

from .library import Library
from .middlewares.enclosing import REMOVED_ENCLOSING_KEY
from .middlewares.middleware import Middleware
from .middlewares.parsestack import default_parse_stack
from .middlewares.parsestack import default_unparse_stack
from .model import Block
from .model import String
from .splitter import Splitter
from .writer import BibtexFormat
from .writer import write

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


def _build_parse_stack(
parse_stack: Iterable[Middleware] | None,
Expand Down Expand Up @@ -137,20 +144,97 @@ def parse_string(
(ignored if a not-``None`` parse_stack is passed).

:param library:
Library to add entries to. If ``None`` (default), a new library will be created.
Library to add the newly parsed blocks to.
If ``None`` (default), a new library is created and returned.
If a library is passed, it is returned (mutated) and:

- the parse stack is applied **only** to the newly parsed blocks;
blocks already contained in the passed library are left untouched
(they were already transformed when they were parsed);
- ``@string`` blocks already contained in the passed library are visible
to the parse stack, i.e. string references in ``bibtex_str`` resolve
against them (unless ``bibtex_str`` redefines the same key);
- keys defined both in the passed library and in ``bibtex_str``
do not raise, but yield ``DuplicateBlockKeyBlock`` instances
(see ``library.failed_blocks``), just like duplicates within a single string.

:return: Library: Parsed BibTeX database
"""
splitter = Splitter(bibstr=bibtex_str)
library = splitter.split(library=library)
parsed = splitter.split()

_seed_preexisting_strings(parsed, library)

middleware: Middleware
for middleware in _build_parse_stack(parse_stack, append_middleware):
library = middleware.transform(library=library)
parsed = middleware.transform(library=parsed)

if library is None:
return parsed

new_blocks = [b for b in parsed.blocks if not _is_seeded_string(b)]
library.add(new_blocks, fail_on_duplicate_key=False)
return library


def _is_seeded_string(block: Block) -> bool:
"""True for blocks seeded by `_seed_preexisting_strings` (and their transformations)."""
return bool(block.get_parser_metadata(_PREEXISTING_STRING_KEY))


def _restore_enclosing(string: String) -> None:
"""Make sure the value of an already-parsed string is enclosed again.

The parse stack expects freshly split (i.e. still enclosed) values.
Feeding it an already-stripped value would make that value be treated
as an unenclosed literal (a string reference), which does not round-trip
to valid bibtex.
"""
enclosing = string.parser_metadata.pop(REMOVED_ENCLOSING_KEY, None)
if string.enclosing == "no-enclosing" or enclosing == "no-enclosing":
return
value = string.value
if not isinstance(value, str):
return
if enclosing is None and (
(value.startswith("{") and value.endswith("}"))
or (value.startswith('"') and value.endswith('"'))
):
return
string.value = f'"{value}"' if enclosing == '"' else f"{{{value}}}"


def _seed_preexisting_strings(parsed: Library, library: Library | None) -> list[String]:
"""Make the ``@string`` blocks of an existing library visible to the parse stack.

Copies (never the originals, which must not be transformed again) of the
strings of ``library`` are added to ``parsed``, unless the newly parsed
content redefines the same key. The copies are tagged so that they can be
dropped again before merging the parsed blocks back into ``library``.

:param parsed: The freshly split library, modified in place.
:param library: The pre-existing library, or ``None``.
:return: The seeded (tagged) string copies.
"""
if library is None:
return []

# Bibtex string keys are case-insensitive, hence compare in lower case.
redefined = {key.lower() for key in parsed.strings_dict}
seeds = []
for key, string in library.strings_dict.items():
if key.lower() in redefined:
continue
seed = deepcopy(string)
seed.set_parser_metadata(_PREEXISTING_STRING_KEY, True)
_restore_enclosing(seed)
seeds.append(seed)

if seeds:
parsed.add(seeds, fail_on_duplicate_key=False)
return seeds


def parse_file(
path: str,
parse_stack: Iterable[Middleware] | None = None,
Expand Down
160 changes: 160 additions & 0 deletions tests/test_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
import pytest

from bibtexparser import parse_file
from bibtexparser import parse_string
from bibtexparser import write_file
from bibtexparser import write_string
from bibtexparser.library import Library
from bibtexparser.model import DuplicateBlockKeyBlock
from bibtexparser.model import Entry
from bibtexparser.model import Field
from bibtexparser.model import String


def test_gbk():
Expand Down Expand Up @@ -241,3 +244,160 @@ def test_write_string_unexpected_keyword_argument_raises_error():
write_string(library, unknown_param="value")
assert "unexpected keyword arguments" in str(excinfo.value)
assert "unknown_param" in str(excinfo.value)


FIRST_BIBTEX = """@string{me = "My Name"}

@article{first,
title = {Hello World},
author = me,
year = 2023
}"""


def test_parse_string_into_existing_library_returns_the_passed_library():
"""The passed library is mutated and returned, no new instance is created."""
library = parse_string(FIRST_BIBTEX)
returned = parse_string("@article{second, title = {Second}}", library=library)
assert returned is library


def test_parse_string_into_existing_library_keeps_previous_blocks_untouched():
"""Blocks already in the library must not be transformed a second time."""
library = parse_string(FIRST_BIBTEX)
first_entry = library.entries_dict["first"]
first_string = library.strings_dict["me"]

parse_string("@article{second, title = {Second}}", library=library)

assert library.entries_dict["first"] is first_entry
assert library.strings_dict["me"] is first_string
assert first_entry["title"] == "Hello World"
assert first_entry["author"] == "My Name"
assert first_entry["year"] == "2023"
assert first_string.value == "My Name"
assert all(field.enclosing is None for field in first_entry.fields)
assert first_string.enclosing is None


def test_parse_string_into_existing_library_does_not_warn():
"""The parse stack must not be re-applied, hence no enclosing-order warning."""
library = parse_string(FIRST_BIBTEX)
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
parse_string("@article{second, title = {Second}, author = me}", library=library)
assert [str(warning.message) for warning in w] == []


def test_parse_string_into_existing_library_roundtrips():
"""The resulting library must still serialize to valid (re-parsable) bibtex."""
library = parse_string(FIRST_BIBTEX)
parse_string("@article{second, title = {Second}, author = me}", library=library)

written = write_string(library)
assert "title = {Hello World}" in written
assert "author = {My Name}" in written
assert "@string{me = {My Name}}" in written

reparsed = parse_string(written)
assert reparsed.failed_blocks == []
assert reparsed.entries_dict["first"]["title"] == "Hello World"
assert reparsed.entries_dict["first"]["author"] == "My Name"
assert reparsed.entries_dict["second"]["author"] == "My Name"
assert reparsed.strings_dict["me"].value == "My Name"


def test_parse_string_into_existing_library_is_equivalent_to_parsing_at_once():
"""Parsing in two steps must yield the same bibtex as parsing everything at once."""
second_bibtex = "@article{second, title = {Second}, author = me}"

stepwise = parse_string(FIRST_BIBTEX)
parse_string(second_bibtex, library=stepwise)
at_once = parse_string(FIRST_BIBTEX + "\n\n" + second_bibtex)

assert write_string(stepwise) == write_string(at_once)


def test_parse_string_into_existing_library_resolves_previously_defined_strings():
"""String references in the new content resolve against earlier @string blocks."""
library = parse_string(FIRST_BIBTEX)
parse_string("@article{second, author = me}", library=library)

assert library.entries_dict["second"]["author"] == "My Name"
assert len(library.strings) == 1
assert [type(block) for block in library.blocks].count(String) == 1


def test_parse_string_into_existing_library_resolves_strings_case_insensitively():
"""As within a single parse, string references are case-insensitive."""
library = parse_string(FIRST_BIBTEX)
parse_string("@article{second, author = ME}", library=library)
assert library.entries_dict["second"]["author"] == "My Name"


def test_parse_string_repeatedly_into_existing_library():
"""More than two consecutive calls keep working (no accumulating corruption)."""
library = parse_string(FIRST_BIBTEX)
for key in ("second", "third", "fourth"):
parse_string(f"@article{{{key}, author = me}}", library=library)

assert len(library.entries) == 4
assert library.failed_blocks == []
assert all(entry["author"] == "My Name" for entry in library.entries)
assert write_string(library).count("author = {My Name}") == 4


def test_parse_string_into_existing_library_duplicate_entry_key():
"""A duplicate entry key across two calls yields a DuplicateBlockKeyBlock."""
library = parse_string("@article{duplicate, title = {First}}")
parse_string("@article{duplicate, title = {Second}}", library=library)

assert len(library.failed_blocks) == 1
failed = library.failed_blocks[0]
assert isinstance(failed, DuplicateBlockKeyBlock)
assert failed.key == "duplicate"
assert library.entries_dict["duplicate"]["title"] == "First"


def test_parse_string_into_existing_library_redefined_string_key():
"""A @string redefined by the new content becomes a DuplicateBlockKeyBlock,
but is used to resolve references within the newly parsed content."""
library = parse_string('@string{me = "Old"}\n@article{first, author = me}')
parse_string('@string{me = "New"}\n@article{second, author = me}', library=library)

assert library.strings_dict["me"].value == "Old"
assert len(library.failed_blocks) == 1
assert isinstance(library.failed_blocks[0], DuplicateBlockKeyBlock)
assert library.failed_blocks[0].key == "me"

assert library.entries_dict["first"]["author"] == "Old"
assert library.entries_dict["second"]["author"] == "New"


def test_parse_string_into_manually_created_library():
"""Strings of a hand-built (never parsed) library are usable as references."""
library = Library(
[
Entry("article", "manual", [Field("title", "Manual")]),
String("manual_string", "Some Value"),
]
)
parse_string("@article{parsed, author = manual_string}", library=library)

assert library.entries_dict["parsed"]["author"] == "Some Value"
assert library.entries_dict["manual"]["title"] == "Manual"
assert "author = {Some Value}" in write_string(library)


def test_parse_string_into_existing_library_keeps_block_order():
"""Newly parsed blocks are appended after the pre-existing ones."""
library = parse_string("% first comment\n@article{first, title = {First}}")
parse_string("% second comment\n@article{second, title = {Second}}", library=library)

assert [block.__class__.__name__ for block in library.blocks] == [
"ImplicitComment",
"Entry",
"ImplicitComment",
"Entry",
]
assert [entry.key for entry in library.entries] == ["first", "second"]
Loading