diff --git a/bibtexparser/middlewares/__init__.py b/bibtexparser/middlewares/__init__.py index 7202200d..fd5befa1 100644 --- a/bibtexparser/middlewares/__init__.py +++ b/bibtexparser/middlewares/__init__.py @@ -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 diff --git a/bibtexparser/middlewares/entrytypes.py b/bibtexparser/middlewares/entrytypes.py new file mode 100644 index 00000000..863c0bf4 --- /dev/null +++ b/bibtexparser/middlewares/entrytypes.py @@ -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 diff --git a/bibtexparser/splitter.py b/bibtexparser/splitter.py index bbc3e97f..3ca5ead5 100644 --- a/bibtexparser/splitter.py +++ b/bibtexparser/splitter.py @@ -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( @@ -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 diff --git a/docs/source/customize.rst b/docs/source/customize.rst index 9b8a2ae4..c6065305 100644 --- a/docs/source/customize.rst +++ b/docs/source/customize.rst @@ -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 diff --git a/tests/middleware_tests/test_entrytypes.py b/tests/middleware_tests/test_entrytypes.py new file mode 100644 index 00000000..4da399e1 --- /dev/null +++ b/tests/middleware_tests/test_entrytypes.py @@ -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" diff --git a/tests/splitter_tests/test_splitter_basic.py b/tests/splitter_tests/test_splitter_basic.py index 58be5c46..ccc9a3bb 100644 --- a/tests/splitter_tests/test_splitter_basic.py +++ b/tests/splitter_tests/test_splitter_basic.py @@ -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" @@ -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" @@ -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" diff --git a/tests/splitter_tests/test_splitter_entry.py b/tests/splitter_tests/test_splitter_entry.py index 5d57da0e..89f1d0cc 100644 --- a/tests/splitter_tests/test_splitter_entry.py +++ b/tests/splitter_tests/test_splitter_entry.py @@ -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" }""" @@ -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) @@ -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 diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index 43ace6c7..f1a9c110 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -7,6 +7,7 @@ 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 @@ -14,6 +15,26 @@ 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"] == "凯撒"