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
9 changes: 9 additions & 0 deletions bibtexparser/splitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,13 @@ def _move_to_end_of_entry(self, first_key_start: int) -> tuple[list[Field], int,
while True:
equals_mark = self._next_mark(accept_eof=False)
if equals_mark.group(0) == "}":
dangling_key = self.bibstr[key_start : equals_mark.start()].strip()
if dangling_key:
raise BlockAbortedException(
abort_reason=f"Expected a `=` after entry key `{dangling_key}`, "
"but found the end of the entry (`}`).",
end_index=equals_mark.end(),
)
# End of entry
return result, equals_mark.end(), duplicate_keys

Expand Down Expand Up @@ -266,6 +273,8 @@ def _move_to_end_of_entry(self, first_key_start: int) -> tuple[list[Field], int,
elif after_field_mark.group(0) == "}":
# If next mark is a closing bracket, put it back (will return in next loop iteration)
self._unaccepted_mark = after_field_mark
# Advance past the value, else the check above aborts a valid entry.
key_start = after_field_mark.start()
continue
else:
self._unaccepted_mark = after_field_mark
Expand Down
55 changes: 55 additions & 0 deletions tests/splitter_tests/test_splitter_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,58 @@ def test_escaped_quotes_in_field_value(bibtex_str: str, expected_title: str):
assert len(library.failed_blocks) == 0
assert len(library.entries) == 1
assert library.entries[0].fields_dict["title"].value == expected_title


@pytest.mark.parametrize(
"entry, dangling_key",
[
pytest.param(
"@article{test, title = {Some title}, year}",
"year",
id="dangling key after regular field",
),
pytest.param(
"@article{test, title = {Some title},\n year\n}",
"year",
id="dangling key on its own line",
),
pytest.param("@article{test, title}", "title", id="entry with only a dangling key"),
],
)
def test_entry_with_dangling_key(entry: str, dangling_key: str):
"""A key without a `=` and value must not be silently dropped.

Such an entry is malformed and must end up in the failed blocks,
instead of being reported as a successfully parsed (but incomplete) entry.
"""
subsequent_article = "@Article{subsequentArticle, title = {Some title}}"
full_bibtex = f"{entry}\n\n{subsequent_article}"
library: Library = Splitter(full_bibtex).split()

assert len(library.failed_blocks) == 1
assert dangling_key in library.failed_blocks[0].error.abort_reason
assert library.failed_blocks[0].raw == entry

assert len(library.entries) == 1
assert library.entries[0].key == "subsequentArticle"
assert len(library.entries[0].fields) == 1


@pytest.mark.parametrize(
"entry",
[
pytest.param("@article{test, title = {Some title}}", id="regular last field"),
pytest.param("@article{test, title = {Some title}, }", id="trailing comma"),
pytest.param("@article{test, title = {Some title},\n}", id="trailing comma and newline"),
pytest.param('@article{test, title = "Some title", }', id="quoted, trailing comma"),
pytest.param("@article{test, title = SomeString, }", id="unenclosed, trailing comma"),
pytest.param("@article{test, title = {Some {nested} title}}", id="nested braces"),
],
)
def test_entry_without_dangling_key_is_not_aborted(entry: str):
"""Entries legally ending in `}` (with or without trailing comma) still parse cleanly."""
library: Library = Splitter(entry).split()
assert len(library.failed_blocks) == 0
assert len(library.entries) == 1
assert len(library.entries[0].fields) == 1
assert library.entries[0].fields[0].key == "title"
5 changes: 5 additions & 0 deletions tests/splitter_tests/test_splitter_parse_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
"""@article{article1, title="title1 author={author1}""",
id="field_missing_closing_quote",
),
pytest.param(
"""@article{article1, title={title1}, danglingKey}""",
id="entry_with_dangling_key",
),
pytest.param("""@article{article1, danglingKey}""", id="entry_with_only_dangling_key"),
pytest.param("""@string{foo = "bar""", id="string_without_closing_brace"),
pytest.param("""@preamble{e = mc^2""", id="preable_without_closing_brace"),
pytest.param("""@comment{foo = "bar""", id="preable_without_closing_brace"),
Expand Down
Loading