Repository: sciunto-org/python-bibtexparser
Pull request #312 (2022-07-10 15:12:05)

:children_crossing: Raise warning if parser is used multiple times
Fixes https://github.com/sciunto-org/python-bibtexparser/issues/308. A warning message will appear if the `parse` method is called more than once and the message will be "Parser has been called more than once."

MiWeiss: Excuse my late reply. 

On some files, you changed the indentation of the lines, which makes this PR a bit verbose. Would you mind reverting these? 
mrn97: I fixed the indentation as it was before.
mrn97: > FYI: I do not dislike the changed indentations, but the PR should be self-contained and only consist of the changes relevant to the new feature (the warning), to keep the commit history as legible as possible.

I understand, you are right, it's now much cleaner and less verbose, I had to use `diff` from the beginning, I double-checked them using `git diff master issue308 file.py` and I think that's ok, but if you see any other errors I can fix it.
MiWeiss: Thanks :-)

We're almost done. Unfortunately, I had previously not noticed that the warning provides relatively little information. In my review, I suggested a slightly different warning. Feel free to change my suggestion, however :-)

Afterward, if nothing else pops up, we should be ready to merge 🎉 

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 454a977..7e4758a 100644
--- a/bibtexparser/bwriter.py
+++ b/bibtexparser/bwriter.py
@@ -58,6 +58,9 @@ class BibTexWriter(object):
         #: Align values. Determines the maximal number of characters used in any fieldname and aligns all values
         #    according to that by filling up with single spaces. Default: False
         self.align_values = False
+        #: Align multi-line values. Formats a multi-line value such that the text is aligned exactly
+        #    on top of each other. Default: False
+        self.align_multiline_values = False
         #: Characters(s) for separating BibTeX entries. Default: new line.
         self.entry_separator = '\n'
         #: Tuple of fields for ordering BibTeX entries. Set to `None` to disable sorting. Default: BibTeX key `('ID', )`.
@@ -127,11 +130,31 @@ class BibTexWriter(object):
         # Write field = value lines
         for field in [i for i in display_order if i not in ['ENTRYTYPE', 'ID']]:
             try:
+                value = _str_or_expr_to_bibtex(entry[field])
+
+                if self.align_multiline_values:
+                    # Calculate indent of multi-line values. Text from a multiline string
+                    # should be aligned, i.e., be exactly on top of each other.
+                    # E.g.:       title = {Hello
+                    #                      World}
+                    # Calculate the indent of "World":
+                    # Left of field (whitespaces before e.g. 'title')
+                    value_indent = len(self.indent)
+                    # Field itself (e.g. len('title'))
+                    if self._max_field_width > 0:
+                        value_indent += self._max_field_width
+                    else:
+                        value_indent += len(field)
+                    # Right of field ' = ' (<- 3 chars) + '{' (<- 1 char)
+                    value_indent += 3 + 1
+
+                    value = value.replace('\n', '\n' + value_indent * ' ')
+
                 bibtex += field_fmt.format(
                     indent=self.indent,
                     field=field,
                     field_max_w=self._max_field_width,
-                    value=_str_or_expr_to_bibtex(entry[field]))
+                    value=value)
             except TypeError:
                 raise TypeError(u"The field %s in entry %s must be a string"
                                 % (field, entry['ID']))


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/data/article_multilines.bib b/bibtexparser/tests/data/article_multilines.bib
new file mode 100644
index 0000000..7a6bbe3
--- /dev/null
+++ b/bibtexparser/tests/data/article_multilines.bib
@@ -0,0 +1,13 @@
+@ARTICLE{Cesar2013,
+  author = {Jean César},
+  title = {A mutline line title is very amazing. It should be
+long enough to test multilines... with two lines or should we
+even test three lines... What an amazing title.},
+  year = {2013},
+  journal = {Nice Journal},
+  abstract = {This is an abstract. This line should be long enough to test
+multilines... and with a french érudit word},
+  comments = {A comment},
+  keyword = {keyword1, keyword2,
+multiline-keyword1, multiline-keyword2},
+}
diff --git a/bibtexparser/tests/test_bibtexwriter.py b/bibtexparser/tests/test_bibtexwriter.py
index b7a83c6..0d8fafb 100644
--- a/bibtexparser/tests/test_bibtexwriter.py
+++ b/bibtexparser/tests/test_bibtexwriter.py
@@ -169,6 +169,106 @@ class TestBibTexWriter(unittest.TestCase):
  title = {Optical fiber fusion slicing},
  author = {Yablon, A.D.}
 }
+"""
+        self.assertEqual(result, expected)
+
+    def test_align_multiline_values(self):
+        with open('bibtexparser/tests/data/article_multilines.bib') as bibtex_file:
+            bib_database = bibtexparser.load(bibtex_file)
+        writer = BibTexWriter()
+        writer.align_multiline_values = True
+        writer.display_order = ["author", "title", "year", "journal", "abstract", "comments", "keyword"]
+        result = bibtexparser.dumps(bib_database, writer)
+        expected = \
+"""@article{Cesar2013,
+ author = {Jean César},
+ title = {A mutline line title is very amazing. It should be
+          long enough to test multilines... with two lines or should we
+          even test three lines... What an amazing title.},
+ year = {2013},
+ journal = {Nice Journal},
+ abstract = {This is an abstract. This line should be long enough to test
+             multilines... and with a french érudit word},
+ comments = {A comment},
+ keyword = {keyword1, keyword2,
+            multiline-keyword1, multiline-keyword2}
+}
+"""
+        self.assertEqual(result, expected)
+
+    def test_align_multiline_values_with_align(self):
+        with open('bibtexparser/tests/data/article_multilines.bib') as bibtex_file:
+            bib_database = bibtexparser.load(bibtex_file)
+        writer = BibTexWriter()
+        writer.align_multiline_values = True
+        writer.align_values = True
+        writer.display_order = ["author", "title", "year", "journal", "abstract", "comments", "keyword"]
+        result = bibtexparser.dumps(bib_database, writer)
+        expected = \
+"""@article{Cesar2013,
+ author    = {Jean César},
+ title     = {A mutline line title is very amazing. It should be
+              long enough to test multilines... with two lines or should we
+              even test three lines... What an amazing title.},
+ year      = {2013},
+ journal   = {Nice Journal},
+ abstract  = {This is an abstract. This line should be long enough to test
+              multilines... and with a french érudit word},
+ comments  = {A comment},
+ keyword   = {keyword1, keyword2,
+              multiline-keyword1, multiline-keyword2}
+}
+"""
+        self.assertEqual(result, expected)
+
+    def test_align_multiline_values_with_indent(self):
+        with open('bibtexparser/tests/data/article_multilines.bib') as bibtex_file:
+            bib_database = bibtexparser.load(bibtex_file)
+        writer = BibTexWriter()
+        writer.align_multiline_values = True
+        writer.indent = ' ' * 3
+        writer.display_order = ["author", "title", "year", "journal", "abstract", "comments", "keyword"]
+        result = bibtexparser.dumps(bib_database, writer)
+        expected = \
+"""@article{Cesar2013,
+   author = {Jean César},
+   title = {A mutline line title is very amazing. It should be
+            long enough to test multilines... with two lines or should we
+            even test three lines... What an amazing title.},
+   year = {2013},
+   journal = {Nice Journal},
+   abstract = {This is an abstract. This line should be long enough to test
+               multilines... and with a french érudit word},
+   comments = {A comment},
+   keyword = {keyword1, keyword2,
+              multiline-keyword1, multiline-keyword2}
+}
+"""
+        self.assertEqual(result, expected)
+
+    def test_align_multiline_values_with_align_with_indent(self):
+        with open('bibtexparser/tests/data/article_multilines.bib') as bibtex_file:
+            bib_database = bibtexparser.load(bibtex_file)
+        writer = BibTexWriter()
+        writer.align_multiline_values = True
+        writer.indent = ' ' * 3
+        writer.align_values = True
+        writer.display_order = ["author", "title", "year", "journal", "abstract", "comments", "keyword"]
+        result = bibtexparser.dumps(bib_database, writer)
+        expected = \
+"""@article{Cesar2013,
+   author    = {Jean César},
+   title     = {A mutline line title is very amazing. It should be
+                long enough to test multilines... with two lines or should we
+                even test three lines... What an amazing title.},
+   year      = {2013},
+   journal   = {Nice Journal},
+   abstract  = {This is an abstract. This line should be long enough to test
+                multilines... and with a french érudit word},
+   comments  = {A comment},
+   keyword   = {keyword1, keyword2,
+                multiline-keyword1, multiline-keyword2}
+}
 """
         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()