Repository: sciunto-org/python-bibtexparser
Pull request #444 (2024-01-18 20:04:13)

✅ Create smoke test for custom middleware, and fix typo in docs


Patch:
diff --git a/bibtexparser/middlewares/__init__.py b/bibtexparser/middlewares/__init__.py
index 2cccafe..1f5a938 100644
--- a/bibtexparser/middlewares/__init__.py
+++ b/bibtexparser/middlewares/__init__.py
@@ -7,6 +7,7 @@ from bibtexparser.middlewares.latex_encoding import (
     LatexDecodingMiddleware,
     LatexEncodingMiddleware,
 )
+from bibtexparser.middlewares.middleware import BlockMiddleware, LibraryMiddleware
 from bibtexparser.middlewares.month import (
     MonthAbbreviationMiddleware,
     MonthIntMiddleware,
diff --git a/bibtexparser/middlewares/names.py b/bibtexparser/middlewares/names.py
index 02e74c2..11d4d42 100644
--- a/bibtexparser/middlewares/names.py
+++ b/bibtexparser/middlewares/names.py
@@ -5,7 +5,7 @@ Much of the code is taken from Blair Bonnetts never merged v0 pull request
 """
 import abc
 import dataclasses
-from typing import List, Tuple
+from typing import List, Literal, Tuple
 
 from bibtexparser.model import Block, Entry, Field, MiddlewareErrorBlock
 
@@ -114,6 +114,35 @@ class NameParts:
             ]
         )
 
+    @property
+    def merge_last_name_first(self) -> str:
+        """Merging the name parts into a single string, last-name-first (with commas) format.
+
+        The structure of the output is: `von Last, Jr, First`
+        """
+
+        def escape_last_slash(string: str) -> str:
+            """Escape the last slash in a string if it is not escaped."""
+            # Find the number of trailing slashes
+            stripped = string.rstrip("\\")
+            num_slashes = len(string) - len(stripped)
+            if num_slashes % 2 == 0:
+                # Even number: everything is escaped
+                return string
+            else:
+                # Odd number: need to escape one.
+                return string + "\\"
+
+        first = " ".join(self.first) if self.first else None
+        von = " ".join(self.von) if self.von else None
+        last = " ".join(self.last) if self.last else None
+        jr = " ".join(self.jr) if self.jr else None
+
+        von_last = " ".join(name for name in [von, last] if name)
+        return ", ".join(
+            escape_last_slash(name) for name in [von_last, jr, first] if name
+        )
+
 
 class SplitNameParts(_NameTransformerMiddleware):
     """Middleware to split a persons name into its parts (first, von, last, jr).
@@ -142,8 +171,21 @@ class MergeNameParts(_NameTransformerMiddleware):
     """Middleware to merge a persons name parts (first, von, last, jr) into a single string.
 
     Name fields (e.g. author, editor, translator) are expected to be lists of NameParts.
+
+    The merging style indicates whether:
+    - the merging is done without commas in first-name-first order ("first"), or
+    - the merging is done with commas in last-name-first order ("last").
     """
 
+    def __init__(
+        self,
+        style: Literal["last", "first"] = "last",
+        allow_inplace_modification: bool = True,
+        name_fields: Tuple[str] = ("author", "editor", "translator"),
+    ):
+        self.style = style
+        super().__init__(allow_inplace_modification, name_fields)
+
     # docstr-coverage: inherited
     @classmethod
     def metadata_key(cls) -> str:
@@ -153,7 +195,14 @@ class MergeNameParts(_NameTransformerMiddleware):
         if not isinstance(name, list) and all(isinstance(n, NameParts) for n in name):
             raise ValueError("Expected a list of NameParts, got {}. ".format(name))
 
-        return [n.merge_first_name_first for n in name]
+        if self.style == "last":
+            return [n.merge_last_name_first for n in name]
+        elif self.style == "first":
+            return [n.merge_first_name_first for n in name]
+        else:
+            raise ValueError(
+                """Expected "first" or "last" style, got {}. """.format(self.style)
+            )
 
 
 def parse_single_name_into_parts(name, strict=True):
@@ -229,33 +278,38 @@ def parse_single_name_into_parts(name, strict=True):
     for char in nameiter:
         # An escape.
         if char == "\\":
-            escaped = next(nameiter)
-
-            # BibTeX doesn't allow whitespace escaping. Copy the slash and fall
-            # through to the normal case to handle the whitespace.
-            if escaped in whitespace:
-                word.append(char)
-                char = escaped
-
-            else:
-                # Is this the first character in a brace?
-                if bracestart:
-                    bracestart = False
-                    controlseq = escaped.isalpha()
-                    specialchar = True
-
-                # Can we use it to determine the case?
-                elif (case == -1) and escaped.isalpha():
-                    if escaped.isupper():
-                        case = 1
-                    else:
-                        case = 0
-
-                # Copy the escape to the current word and go to the next
-                # character in the input.
+            try:
+                escaped = next(nameiter)
+
+                # BibTeX doesn't allow whitespace escaping. Copy the slash and fall
+                # through to the normal case to handle the whitespace.
+                if escaped in whitespace:
+                    word.append(char)
+                    char = escaped
+
+                else:
+                    # Is this the first character in a brace?
+                    if bracestart:
+                        bracestart = False
+                        controlseq = escaped.isalpha()
+                        specialchar = True
+
+                    # Can we use it to determine the case?
+                    elif (case == -1) and escaped.isalpha():
+                        if escaped.isupper():
+                            case = 1
+                        else:
+                            case = 0
+
+                    # Copy the escape to the current word and go to the next
+                    # character in the input.
+                    word.append(char)
+                    word.append(escaped)
+                    continue
+
+            # If we're at the end of the string, then the \ is just a \.
+            except StopIteration:
                 word.append(char)
-                word.append(escaped)
-                continue
 
         # Start of a braced expression.
         if char == "{":
@@ -510,7 +564,11 @@ def split_multiple_persons_names(names):
 
         # Escaped character.
         if char == "\\":
-            next(namesiter)
+            try:
+                next(namesiter)
+            # If we're at the end of the string, then the \ is just a \.
+            except StopIteration:
+                pass
             pos += 1
             continue
 
diff --git a/docs/source/customize.rst b/docs/source/customize.rst
index c03e433..3ff81ce 100644
--- a/docs/source/customize.rst
+++ b/docs/source/customize.rst
@@ -113,7 +113,7 @@ and share them with the community.
 We will be happy to provide a list of community-provided middleware layers here, so please let us know if you have written one!
 
 .. note::
-    To write your own middleware, simply extend the :class:`bibtexparser.middlewares.Blockmiddleware`
+    To write your own middleware, simply extend the :class:`bibtexparser.middlewares.BlockMiddleware`
     (for functions working on blocks individually, such as encoding) or :class:`bibtexparser.middlewares.LibraryMiddleware`
     (for library-wide transformations, such as sorting blocks) and implement the superclass methods
     according to the python docstrings. Make sure to check out some core middleware layers for examples.


Test patch:
diff --git a/tests/middleware_tests/test_custom_middleware_smoke.py b/tests/middleware_tests/test_custom_middleware_smoke.py
new file mode 100644
index 0000000..7ef487f
--- /dev/null
+++ b/tests/middleware_tests/test_custom_middleware_smoke.py
@@ -0,0 +1,33 @@
+"""Smoke test for custom middleware, based on the #440 by @tdegeus"""
+
+import bibtexparser
+from bibtexparser.middlewares.middleware import BlockMiddleware
+
+bibtex_str = """
+@ARTICLE{Cesar2013,
+  author = {Jean César and T. W. J. de Geus},
+  title = {An amazing title},
+  year = {2013},
+  pages = {12--23},
+  volume = {12},
+  journal = {Nice Journal}
+}
+"""
+
+
+abbreviations = {"Nice Journal": "NJ", "Another Nice Journal": "ANJ"}
+
+
+class JournalAbbreviate(BlockMiddleware):
+    def transform_entry(self, entry, *args, **kwargs):
+        if entry["journal"] in abbreviations:
+            entry["journal"] = abbreviations[entry["journal"]]
+        return entry
+
+
+def test_custom_middleware_smoke():
+    """Test that the very simple custom middleware above works."""
+    library = bibtexparser.parse_string(
+        bibtex_str, append_middleware=[JournalAbbreviate(True)]
+    )
+    assert library.entries[0]["journal"] == "NJ"
diff --git a/tests/middleware_tests/test_names.py b/tests/middleware_tests/test_names.py
index fb5e6bc..8dde269 100644
--- a/tests/middleware_tests/test_names.py
+++ b/tests/middleware_tests/test_names.py
@@ -860,6 +860,39 @@ def test_split_name_into_parts(name, expected_as_dict, strict):
     assert result == expected
 
 
+@pytest.mark.parametrize(
+    "name, expected_as_dict", REGULAR_NAME_PARTS_PARSING_TEST_CASES
+)
+@pytest.mark.parametrize("strict", [True, False], ids=["strict", "non-strict"])
+def test_merge_last_name_first_inverse(name, expected_as_dict, strict):
+    """Tests that merging name parts using the last-name-first method
+    maintains the "semantics" of the name.
+
+    This property does not hold for certain values that contain '\\'.
+    """
+
+    # cases where either the last name or "von" part ends with an odd number of `\` cannot be handled,
+    # since in those cases the `,` is escaped when the name parts are put back together
+    def ends_with_odd_slash(names: List[str]) -> bool:
+        if len(names) == 0:
+            return False
+        name = names[-1]
+        count = 0
+        i = len(name) - 1
+        while i >= 0 and name[i] == "\\":
+            count += 1
+            i -= 1
+        return count % 2 == 1
+
+    if any(ends_with_odd_slash(name) for name in expected_as_dict.values()):
+        pytest.skip("Inverse property does not hold for names ending with '\\'")
+
+    nameparts = _dict_to_nameparts(expected_as_dict)
+    merged = nameparts.merge_last_name_first
+    resplit = parse_single_name_into_parts(merged, strict=strict)
+    assert resplit == nameparts
+
+
 @pytest.mark.parametrize("inplace", [True, False], ids=["inplace", "copy"])
 def test_separate_co_names_middleware(inplace):
     """Test coauthor, co-editor, splitting middleware.
@@ -966,7 +999,25 @@ def test_split_name_parts(inplace: bool):
 
 
 @pytest.mark.parametrize("inplace", [True, False], ids=["inplace", "copy"])
-def test_merge_name_parts(inplace: bool):
+@pytest.mark.parametrize(
+    ("style", "names"),
+    [
+        (
+            "first",
+            ["Amy Author", "Ben Bystander", "Carl Carpooler\\", "Donald Doctor\\\\"],
+        ),
+        (
+            "last",
+            [
+                "Author, Amy",
+                "Bystander, Ben",
+                "Carpooler\\\\, Carl",
+                "Doctor\\\\, Donald",
+            ],
+        ),
+    ],
+)
+def test_merge_name_parts(inplace: bool, style: str, names: List[str]):
     input_entry = Entry(
         start_line=0,
         raw="irrelevant-for-this-test",
@@ -980,13 +1031,15 @@ def test_merge_name_parts(inplace: bool):
                 value=[
                     NameParts(first=["Amy"], last=["Author"], von=[], jr=[]),
                     NameParts(first=["Ben"], last=["Bystander"], von=[], jr=[]),
+                    NameParts(first=["Carl"], last=["Carpooler\\"], von=[], jr=[]),
+                    NameParts(first=["Donald"], last=["Doctor\\\\"], von=[], jr=[]),
                 ],
             ),
         ],
     )
     original_copy = deepcopy(input_entry)
 
-    middleware = MergeNameParts(allow_inplace_modification=inplace)
+    middleware = MergeNameParts(style=style, allow_inplace_modification=inplace)
     transformed_library = middleware.transform(Library([input_entry]))
 
     assert len(transformed_library.entries) == 1
@@ -994,10 +1047,7 @@ def test_merge_name_parts(inplace: bool):
 
     transformed_entry = transformed_library.entries[0]
     assert transformed_entry.fields_dict["title"] == original_copy.fields_dict["title"]
-    assert transformed_entry.fields_dict["author"].value == [
-        "Amy Author",
-        "Ben Bystander",
-    ]
+    assert transformed_entry.fields_dict["author"].value == names
 
     # Make sure other attributes are not changed
     assert_nonfield_entry_attributes_unchanged(original_copy, transformed_entry)