Repository: sciunto-org/python-bibtexparser
Pull request #317 (2022-09-05 20:43:34)

✨Allow preservation of existing order of entry fields in writer
Fixes #277

Add different sorting strategies for elements that are added at the end of `display_order`. I'm not sure if we should use a `enum` for `display_order_sorting` or any other global value.. A enum is most convenient I think.

MiWeiss: Thanks @michaelfruth for your PRs. Much appreciated.

I'm currently quite busy, and it will probably take a couple of days until I get to submit my reviews(*). Feel free to bump me if you think it's taking to long ;-)


(*) After a short glimpse, I do not expect any major comments...

Patch:
diff --git a/.gitignore b/.gitignore
index 96cb234..81d91b1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,6 @@ nosetests.xml
 
 # Vim.
 *.swp
+
+# Vscode
+.vscode
diff --git a/bibtexparser/bparser.py b/bibtexparser/bparser.py
index d38855f..34620a4 100644
--- a/bibtexparser/bparser.py
+++ b/bibtexparser/bparser.py
@@ -8,6 +8,7 @@
 
 import io
 import logging
+import warnings
 
 from bibtexparser.bibdatabase import (BibDatabase, BibDataString, as_text,
                                       BibDataStringExpression, STANDARD_TYPES)
@@ -82,6 +83,12 @@ class BibTexParser(object):
         :return: parser
         :rtype: `BibTexParser`
         """
+
+        self._parse_call_count = 0
+        #: Parsers can be called multiple times, merging databases, but raising a warning.
+        #      Set this to `True` to disable the warning. Default: `False`
+        self.expect_multiple_parse = False
+
         self.bib_database = BibDatabase()
 
         #: Load common strings such as months abbreviation
@@ -142,6 +149,14 @@ class BibTexParser(object):
         :return: bibliographic database
         :rtype: BibDatabase
         """
+
+        self._parse_call_count += 1
+        if self._parse_call_count == 2 and not self.expect_multiple_parse:
+            warnings.warn("The parser has been called more than once. "
+                          "Subsequent parse calls lead to a combined BibTeX library. \n"
+                          "To avoid the warning, set property `parser.expect_multiple_parse` to `True`.",
+                          category=UserWarning, stacklevel=2)
+
         bibtex_file_obj = self._bibtex_file_obj(bibtex_str)
         try:
             self._expr.parseFile(bibtex_file_obj)
diff --git a/bibtexparser/bwriter.py b/bibtexparser/bwriter.py
index 7e4758a..67190e6 100644
--- a/bibtexparser/bwriter.py
+++ b/bibtexparser/bwriter.py
@@ -4,6 +4,8 @@
 
 
 import logging
+from enum import Enum, auto
+from typing import Dict, Callable, Iterable
 from bibtexparser.bibdatabase import (BibDatabase, COMMON_STRINGS,
                                       BibDataString,
                                       BibDataStringExpression)
@@ -14,6 +16,38 @@ logger = logging.getLogger(__name__)
 __all__ = ['BibTexWriter']
 
 
+class SortingStrategy(Enum):
+    """
+    Defines different strategies for sorting the entries not defined in :py:attr:`~.BibTexWriter.display_order` and that are added at the end.
+    """
+    ALPHABETICAL_ASC = auto()
+    """
+    Alphabetical sorting in ascending order.
+    """
+    ALPHABETICAL_DESC = auto()
+    """
+    Alphabetical sorting in descending order.
+    """
+    PRESERVE = auto()
+    """
+    Preserves the order of the entries. Entries are not sorted.
+    """
+
+
+def _apply_sorting_strategy(strategy: SortingStrategy, items: Iterable[str]) -> Iterable[str]:
+    """
+    Sorts the items based on the given sorting strategy.
+    """
+    if strategy == SortingStrategy.ALPHABETICAL_ASC:
+        return sorted(items)
+    elif strategy == SortingStrategy.ALPHABETICAL_DESC:
+        return reversed(sorted(items))
+    elif strategy == SortingStrategy.PRESERVE:
+        return items
+    else:
+        raise NotImplementedError(f"The strategy {strategy.name} is not implemented.")
+
+
 def to_bibtex(parsed):
     """
     Convenience function for backwards compatibility.
@@ -65,9 +99,12 @@ class BibTexWriter(object):
         self.entry_separator = '\n'
         #: Tuple of fields for ordering BibTeX entries. Set to `None` to disable sorting. Default: BibTeX key `('ID', )`.
         self.order_entries_by = ('ID', )
-        #: Tuple of fields for display order in a single BibTeX entry. Fields not listed here will be displayed
-        #: alphabetically at the end. Set to '[]' for alphabetical order. Default: '[]'
+        #: Tuple of fields for display order in a single BibTeX entry. Fields not listed here will be displayed at the
+        #    end in the order defined by display_order_sorting. Default: '[]'
         self.display_order = []
+        # Sorting strategy for entries not contained in display_order. Entries not defined in display_order are added
+        #    at the end in the order defined by this strategy. Default: SortingStrategy.ALPHABETICAL_ASC
+        self.display_order_sorting: SortingStrategy = SortingStrategy.ALPHABETICAL_ASC
         #: BibTeX syntax allows comma first syntax
         #: (common in functional languages), use this to enable
         #: comma first syntax as the bwriter output
@@ -122,7 +159,7 @@ class BibTexWriter(object):
         # first those keys which are both in self.display_order and in entry.keys
         display_order = [i for i in self.display_order if i in entry]
         # then all the other fields sorted alphabetically
-        display_order += [i for i in sorted(entry) if i not in self.display_order]
+        display_order += [i for i in _apply_sorting_strategy(self.display_order_sorting, entry) if i not in self.display_order]
         if self.comma_first:
             field_fmt = u"\n{indent}, {field:<{field_max_w}} = {value}"
         else:


Test patch:
diff --git a/bibtexparser/tests/data/article_comma_normal_multiple.bib b/bibtexparser/tests/data/article_comma_normal_multiple.bib
new file mode 100644
index 0000000..e703785
--- /dev/null
+++ b/bibtexparser/tests/data/article_comma_normal_multiple.bib
@@ -0,0 +1,42 @@
+@article{FuMetalhalideperovskite2019,
+    author = {Yongping Fu and Haiming Zhu and Jie Chen and Matthew P. Hautzinger and X.-Y. Zhu and Song Jin},
+    doi = {10.1038/s41578-019-0080-9},
+    journal = {Nature Reviews Materials},
+    month = {feb},
+    number = {3},
+    pages = {169-188},
+    publisher = {Springer Science and Business Media {LLC}},
+    title = {Metal halide perovskite nanostructures for optoelectronic applications and the study of physical properties},
+    url = {https://www.nature.com/articles/s41578-019-0080-9},
+    volume = {4},
+    year = {2019}
+}
+
+@article{SunEnablingSiliconSolar2014,
+    author = {Ke Sun and Shaohua Shen and Yongqi Liang and Paul E. Burrows and Samuel S. Mao and Deli Wang},
+    doi = {10.1021/cr300459q},
+    journal = {Chemical Reviews},
+    month = {aug},
+    number = {17},
+    pages = {8662-8719},
+    publisher = {American Chemical Society ({ACS})},
+    title = {Enabling Silicon for Solar-Fuel Production},
+    url = {http://pubs.acs.org/doi/10.1021/cr300459q},
+    volume = {114},
+    year = {2014}
+}
+
+@article{LiuPhotocatalytichydrogenproduction2016,
+    author = {Maochang Liu and Yubin Chen and Jinzhan Su and Jinwen Shi and Xixi Wang and Liejin Guo},
+    doi = {10.1038/nenergy.2016.151},
+    impactfactor = {54.000},
+    journal = {Nature Energy},
+    month = {sep},
+    number = {11},
+    pages = {16151},
+    publisher = {Springer Science and Business Media {LLC}},
+    title = {Photocatalytic hydrogen production using twinned nanocrystals and an unanchored {NiSx} co-catalyst},
+    url = {http://www.nature.com/articles/nenergy2016151},
+    volume = {1},
+    year = {2016}
+}
diff --git a/bibtexparser/tests/data/article_comma_normal_single.bib b/bibtexparser/tests/data/article_comma_normal_single.bib
new file mode 100644
index 0000000..24a0937
--- /dev/null
+++ b/bibtexparser/tests/data/article_comma_normal_single.bib
@@ -0,0 +1,13 @@
+@article{FuMetalhalideperovskite2019,
+    author = {Yongping Fu and Haiming Zhu and Jie Chen and Matthew P. Hautzinger and X.-Y. Zhu and Song Jin},
+    doi = {10.1038/s41578-019-0080-9},
+    journal = {Nature Reviews Materials},
+    month = {feb},
+    number = {3},
+    pages = {169-188},
+    publisher = {Springer Science and Business Media {LLC}},
+    title = {Metal halide perovskite nanostructures for optoelectronic applications and the study of physical properties},
+    url = {https://www.nature.com/articles/s41578-019-0080-9},
+    volume = {4},
+    year = {2019}
+}
diff --git a/bibtexparser/tests/test_bibtexwriter.py b/bibtexparser/tests/test_bibtexwriter.py
index 0d8fafb..1c875fe 100644
--- a/bibtexparser/tests/test_bibtexwriter.py
+++ b/bibtexparser/tests/test_bibtexwriter.py
@@ -2,7 +2,7 @@
 import tempfile
 import unittest
 import bibtexparser
-from bibtexparser.bwriter import BibTexWriter
+from bibtexparser.bwriter import BibTexWriter, SortingStrategy
 from bibtexparser.bibdatabase import BibDatabase
 
 
@@ -218,6 +218,80 @@ class TestBibTexWriter(unittest.TestCase):
  keyword   = {keyword1, keyword2,
               multiline-keyword1, multiline-keyword2}
 }
+"""
+        self.assertEqual(result, expected)
+
+    def test_display_order_sorting(self):
+        bib_database = BibDatabase()
+        bib_database.entries = [{'ID': 'abc123',
+                                 'ENTRYTYPE': 'book',
+                                 'b': 'test2',
+                                 'a': 'test1',
+                                 'd': 'test4',
+                                 'c': 'test3',
+                                 'e': 'test5'}]
+        # Only 'a' is not ordered. As it's only one element, strategy should not matter.
+        for strategy in SortingStrategy:
+            writer = BibTexWriter()
+            writer.display_order = ['b', 'c', 'd', 'e', 'a']
+            writer.display_order_sorting = strategy
+            result = bibtexparser.dumps(bib_database, writer)
+            expected = \
+"""@book{abc123,
+ b = {test2},
+ c = {test3},
+ d = {test4},
+ e = {test5},
+ a = {test1}
+}
+"""
+            self.assertEqual(result, expected)
+
+        # Test ALPHABETICAL_ASC strategy
+        writer = BibTexWriter()
+        writer.display_order = ['c']
+        writer.display_order_sorting = SortingStrategy.ALPHABETICAL_ASC
+        result = bibtexparser.dumps(bib_database, writer)
+        expected = \
+"""@book{abc123,
+ c = {test3},
+ a = {test1},
+ b = {test2},
+ d = {test4},
+ e = {test5}
+}
+"""
+        self.assertEqual(result, expected)
+
+        # Test ALPHABETICAL_DESC strategy
+        writer = BibTexWriter()
+        writer.display_order = ['c']
+        writer.display_order_sorting = SortingStrategy.ALPHABETICAL_DESC
+        result = bibtexparser.dumps(bib_database, writer)
+        expected = \
+"""@book{abc123,
+ c = {test3},
+ e = {test5},
+ d = {test4},
+ b = {test2},
+ a = {test1}
+}
+"""
+        self.assertEqual(result, expected)
+
+        # Test PRESERVE strategy
+        writer = BibTexWriter()
+        writer.display_order = ['c']
+        writer.display_order_sorting = SortingStrategy.PRESERVE
+        result = bibtexparser.dumps(bib_database, writer)
+        expected = \
+"""@book{abc123,
+ c = {test3},
+ b = {test2},
+ a = {test1},
+ d = {test4},
+ e = {test5}
+}
 """
         self.assertEqual(result, expected)
 
diff --git a/bibtexparser/tests/test_bparser.py b/bibtexparser/tests/test_bparser.py
index 4a9b216..8452f24 100644
--- a/bibtexparser/tests/test_bparser.py
+++ b/bibtexparser/tests/test_bparser.py
@@ -6,7 +6,9 @@ from __future__ import unicode_literals
 import os
 import unittest
 import codecs
+import warnings
 
+import bibtexparser
 from bibtexparser.bparser import BibTexParser
 from bibtexparser.bibdatabase import (COMMON_STRINGS, BibDataStringExpression)
 from bibtexparser.customization import *
@@ -650,6 +652,60 @@ class TestBibtexParserList(unittest.TestCase):
         self.assertEqual(bib.strings, {})
         self.assertEqual(bib.comments, ['@BOOK{, title = "bla"}'])
 
+    def test_parsing_just_once_not_raising_warnings_with_default_settings(self):
+        parser = BibTexParser()
+
+        with open('bibtexparser/tests/data/article_comma_normal_single.bib',
+                  'r', encoding='utf-8') as bibfile:
+            with warnings.catch_warnings(record=True) as warning:
+                warnings.simplefilter("always")
+                bibtexparser.load(bibfile, parser)
+                assert len(warning) == 0
+
+    def test_parsing_twice_raise_warnings_with_default_settings(self):
+        parser = BibTexParser()
+
+        with open('bibtexparser/tests/data/article_comma_normal_single.bib',
+                  'r', encoding='utf-8') as bibfile_first, \
+            open('bibtexparser/tests/data/article_comma_normal_multiple.bib', 'r', encoding='utf-8') \
+                as bibfile_second:
+            with warnings.catch_warnings(record=True) as warning:
+                warnings.simplefilter("always")
+                bibtexparser.load(bibfile_first, parser)
+                bibtexparser.load(bibfile_second, parser)
+                assert len(warning) != 0
+
+    def test_parsing_three_times_raise_warnings_only_once_with_default_settings(self):
+        parser = BibTexParser()
+
+        with open('bibtexparser/tests/data/article_comma_normal_single.bib',
+                  'r', encoding='utf-8') as bibfile_first, \
+            open('bibtexparser/tests/data/article_comma_normal_multiple.bib', 'r', encoding='utf-8') \
+                as bibfile_second, \
+                open('bibtexparser/tests/data/article.bib', 'r', encoding='utf-8') as bibfile_third:
+            with warnings.catch_warnings(record=True) as warning:
+                warnings.simplefilter("always")
+                bibtexparser.load(bibfile_first, parser)
+                bibtexparser.load(bibfile_second, parser)
+                bibtexparser.load(bibfile_third, parser)
+                assert len(warning) == 1
+
+    def test_parsing_three_times_not_raising_a_warning_if_expect_multiple_parse_is_true(self):
+        parser = BibTexParser()
+        parser.expect_multiple_parse = True
+
+        with open('bibtexparser/tests/data/article_comma_normal_single.bib',
+                  'r', encoding='utf-8') as bibfile_first, \
+            open('bibtexparser/tests/data/article_comma_normal_multiple.bib', 'r', encoding='utf-8') \
+                as bibfile_second, \
+                open('bibtexparser/tests/data/article.bib', 'r', encoding='utf-8') as bibfile_third:
+            with warnings.catch_warnings(record=True) as warning:
+                warnings.simplefilter("always")
+                bibtexparser.load(bibfile_first, parser)
+                bibtexparser.load(bibfile_second, parser)
+                bibtexparser.load(bibfile_third, parser)
+                assert len(warning) == 0
+
 
 if __name__ == '__main__':
     unittest.main()