Skip to content
Draft
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
1 change: 1 addition & 0 deletions bibtexparser/middlewares/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from bibtexparser.middlewares.enclosing import AddEnclosingMiddleware
from bibtexparser.middlewares.enclosing import RemoveEnclosingMiddleware
from bibtexparser.middlewares.entrytypes import NormalizeEntryTypes
from bibtexparser.middlewares.fieldkeys import NormalizeFieldKeys
from bibtexparser.middlewares.interpolate import ResolveStringReferencesMiddleware
from bibtexparser.middlewares.latex_encoding import LatexDecodingMiddleware
Expand Down
24 changes: 24 additions & 0 deletions bibtexparser/middlewares/entrytypes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from bibtexparser.library import Library
from bibtexparser.model import Entry

from .middleware import BlockMiddleware


class NormalizeEntryTypes(BlockMiddleware):
"""Normalize entry-type identifiers to lowercase.

Parsing preserves the declared spelling by default so a read/write cycle
does not create case-only changes. Add this middleware when canonical
lowercase entry types are preferred over source preservation.
"""

def __init__(self, allow_inplace_modification: bool = True):
super().__init__(
allow_inplace_modification=allow_inplace_modification,
allow_parallel_execution=True,
)

# docstr-coverage: inherited
def transform_entry(self, entry: Entry, library: Library) -> Entry:
entry.entry_type = entry.entry_type.lower()
return entry
8 changes: 5 additions & 3 deletions bibtexparser/splitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ def split(self, library: Library | None = None) -> Library:
elif m_val.startswith("@string"):
library.add(self._handle_string(m), fail_on_duplicate_key=False)
else:
library.add(self._handle_entry(m, m_val), fail_on_duplicate_key=False)
library.add(self._handle_entry(m), fail_on_duplicate_key=False)

except BlockAbortedException as e:
logger.warning(
Expand Down Expand Up @@ -385,10 +385,12 @@ def _handle_explicit_comment(self) -> ExplicitComment:
raw=self.bibstr[start_index : end_bracket_index + 1],
)

def _handle_entry(self, m, m_val) -> Entry | ParsingFailedBlock:
def _handle_entry(self, m) -> Entry | ParsingFailedBlock:
"""Handle entry block. Return end index"""
start_line = self._current_line
entry_type = m_val[1:].strip()
# Dispatch uses a normalized marker, but retaining the source spelling here prevents
# parse-write round trips from creating case-only changes in BibTeX files.
entry_type = m.group(0)[1:].strip()
start_bracket_mark = self._next_mark(accept_eof=False)
if start_bracket_mark.group(0) != "{":
self._unaccepted_mark = start_bracket_mark
Expand Down
11 changes: 11 additions & 0 deletions docs/source/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ Core Middleware

bibtexparser comes with a number of middleware options:

Identifier Normalization
::::::::::::::::::::::::

Parsing preserves entry-type spelling by default. Use
:class:`bibtexparser.middlewares.NormalizeEntryTypes` when canonical lowercase
types are preferred. Field-key normalization remains a separate choice because
it has different collision behavior:

* :class:`bibtexparser.middlewares.NormalizeEntryTypes`
* :class:`bibtexparser.middlewares.NormalizeFieldKeys`

.. _middleware_encoding:

Encoding and Enclosing of Values
Expand Down
26 changes: 26 additions & 0 deletions tests/middleware_tests/test_entrytypes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Behavioral contracts for explicit entry-type normalization."""

from bibtexparser import parse_string
from bibtexparser import write_string
from bibtexparser.middlewares import NormalizeEntryTypes


def test_entry_type_normalization_is_explicit():
"""Callers can request the lowercase behavior that source-preserving parsing replaced."""
library = parse_string(
"@Article{Mixed, title = {Case}}",
append_middleware=[NormalizeEntryTypes()],
)

assert library.entries[0].entry_type == "article"
assert write_string(library).startswith("@article{Mixed,")


def test_entry_type_normalization_can_avoid_mutating_input():
"""The standard middleware copy policy remains available for reusable libraries."""
library = parse_string("@SoftwarePackage{Mixed, title = {Case}}")

normalized = NormalizeEntryTypes(allow_inplace_modification=False).transform(library)

assert library.entries[0].entry_type == "SoftwarePackage"
assert normalized.entries[0].entry_type == "softwarepackage"
6 changes: 3 additions & 3 deletions tests/splitter_tests/test_splitter_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ def test_handles_duplicate_strings():
},
{
"key": "9837531",
"type": "inproceedings",
"type": "INPROCEEDINGS",
"raw": "@INPROCEEDINGS{9837531,\n"
" author={Hassan, Mona Bakri and Saeed, Rashid A. and Khalifa, Othman and Ali, Elmustafa Sayed and Mokhtar, Rania A. and Hashim, Aisha A.},\n"
" title={Green Machine Learning for Green Cloud Energy Efficiency},\n"
Expand All @@ -357,7 +357,7 @@ def test_handles_duplicate_strings():
},
{
"key": "9372936",
"type": "article",
"type": "ARTICLE",
"raw": "@ARTICLE{9372936,\n"
" author={Hu, Ning and Tian, Zhihong and Du, Xiaojiang and Guizani, Nadra and Zhu, Zhihan},\n"
" title={Deep-Green: A Dispersed Energy-Efficiency Computing Paradigm for Green Industrial IoT},\n"
Expand All @@ -366,7 +366,7 @@ def test_handles_duplicate_strings():
},
{
"key": "5445167",
"type": "article",
"type": "ARTICLE",
"raw": "@ARTICLE{5445167,\n"
" author={Kumar, Karthik and Lu, Yung-Hsiang},\n"
" title={Cloud Computing for Mobile Users: Can Offloading Computation Save Energy?},\n"
Expand Down
40 changes: 20 additions & 20 deletions tests/splitter_tests/test_splitter_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,27 +48,27 @@ def test_field_enclosings(field_key: str, value: str, line: int):


@pytest.mark.parametrize(
"declared_block_type,expected",
"declared_block_type",
[
("@article", "article"),
("@ARTICLE", "article"),
("@Article", "article"),
("@book", "book"),
("@BOOK", "book"),
("@Book", "book"),
("@inbook", "inbook"),
("@INBOOK", "inbook"),
("@Inbook", "inbook"),
("@incollection", "incollection"),
("@INCOLLECTION", "incollection"),
("@Incollection", "incollection"),
("@inproceedings", "inproceedings"),
("@INPROCEEDINGS", "inproceedings"),
("@InprocEEdings", "inproceedings"),
"@article",
"@ARTICLE",
"@Article",
"@book",
"@BOOK",
"@Book",
"@inbook",
"@INBOOK",
"@Inbook",
"@incollection",
"@INCOLLECTION",
"@Incollection",
"@inproceedings",
"@INPROCEEDINGS",
"@InprocEEdings",
],
)
def test_entry_type(declared_block_type, expected):
"""Test that the entry type is case insensitive."""
def test_entry_type_case_is_preserved(declared_block_type):
"""Entry type recognition is case-insensitive without normalizing its spelling."""
bibtex_str = """@article{test,
author = "John Doe"
}"""
Expand All @@ -77,7 +77,7 @@ def test_entry_type(declared_block_type, expected):

assert len(library.failed_blocks) == 0
assert len(library.entries) == 1
assert library.entries[0].entry_type == expected
assert library.entries[0].entry_type == declared_block_type[1:]


@pytest.mark.parametrize("field_value", EDGE_CASE_VALUES)
Expand Down Expand Up @@ -287,7 +287,7 @@ def test_entry_with_space_before_bracket(entry: str):
assert library.entries[0].key == "normal_entry"
assert len(library.entries[0].fields) == 2

assert library.entries[1].entry_type == "article"
assert library.entries[1].entry_type == "Article"
assert library.entries[1].key == "articleTestKey"
assert len(library.entries[1].fields) == 1

Expand Down
21 changes: 21 additions & 0 deletions tests/test_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,34 @@
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 Entry
from bibtexparser.model import Field


def test_entry_type_case_is_preserved_on_string_roundtrip():
"""Writing a parsed entry must not create case-only changes in versioned BibTeX files."""
library = parse_string('@Article{test, title = "Case-sensitive entry type"}')

assert write_string(library).startswith("@Article{test,")


def test_entry_and_field_key_case_is_preserved_on_string_roundtrip():
"""Default parsing and writing retain citation-key and field-key spelling."""
library = parse_string("@article{CamelCaseKey, Author_Keywords = {Systems}}")

entry = library.entries[0]
assert entry.key == "CamelCaseKey"
assert entry.fields[0].key == "Author_Keywords"

written = write_string(library)
assert written.startswith("@article{CamelCaseKey,")
assert "\tAuthor_Keywords = {Systems}" in written


def test_gbk():
library = parse_file("tests/resources/gbk_test.bib", encoding="gbk")
assert library.entries[0]["author"] == "凯撒"
Expand Down
Loading