Repository: texworld/betterbib (language: python)

Issue:
Multiple runs of betterbib-format create ugly `\\\\`-chains
eg for 

```sh
@article{foobar,
title={Foo \& Bar on \LaTeX\ @TheBridge},
}
```

The problem seems to be a missing try to decode the original value beforehand


Discussion / hints:

Fix patch:
diff --git a/setup.cfg b/setup.cfg
index 9ca482d..7b900d7 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = betterbib
-version = 4.0.6
+version = 4.0.7
 author = Nico Schlömer
 author_email = nico.schloemer@gmail.com
 description = Better BibTeX data
diff --git a/src/betterbib/adapt_doi_urls.py b/src/betterbib/adapt_doi_urls.py
index 7ffa8cd..015cfd4 100644
--- a/src/betterbib/adapt_doi_urls.py
+++ b/src/betterbib/adapt_doi_urls.py
@@ -1,7 +1,9 @@
+from typing import Callable
+
 from . import tools
 
 
-def adapt_doi_urls(d, doi_url_type):
+def adapt_doi_urls(d, doi_url_type: str):
     if doi_url_type == "new":
         d = _update_doi_url(d, lambda doi: "https://doi.org/" + doi)
     elif doi_url_type == "short":
@@ -19,7 +21,7 @@ def update_to_short_doi(doi):
     return d
 
 
-def _update_doi_url(d, url_from_doi):
+def _update_doi_url(d, url_from_doi: Callable[[str], str]):
     for bib_id in d:
         if "url" in d[bib_id].fields:
             doi = tools.doi_from_url(d[bib_id].fields["url"])
diff --git a/src/betterbib/cli/_update.py b/src/betterbib/cli/_update.py
index 9ba051d..64b59ac 100644
--- a/src/betterbib/cli/_update.py
+++ b/src/betterbib/cli/_update.py
@@ -24,7 +24,7 @@ def run(args):
             args.in_place,
         )
         d = adapt_doi_urls(d, args.doi_url_type)
-        d = sanitize_title(d)
+        sanitize_title(d)
         d = journal_abbrev(d, args.long_journal_names, args.extra_abbrev_file)
 
         string = to_string(
diff --git a/src/betterbib/tools.py b/src/betterbib/tools.py
index af9d6b6..ec174ce 100644
--- a/src/betterbib/tools.py
+++ b/src/betterbib/tools.py
@@ -6,14 +6,15 @@
 
 # needed for the bibtex_writer
 import sys
+from copy import deepcopy
 from warnings import warn
 
 import appdirs
 import enchant
+import requests
 
 # for enhanced error messages when parsing
-import pybtex.database
-import requests
+from pybtex.database import Entry
 from pybtex.database.input import bibtex
 from pylatexenc.latex2text import LatexNodes2Text
 from pylatexenc.latexencode import unicode_to_latex
@@ -27,15 +28,18 @@
 _config_file = os.path.join(_config_dir, "config.ini")
 
 
-def decode(entry):
+def decode(entry: Entry) -> Entry:
     """Decode a dictionary with LaTeX strings into a dictionary with unicode strings."""
     translator = LatexNodes2Text()
-    for key, value in entry.fields.items():
+    # Perform a deepcopy, otherwise the input entry will get altered
+    out = deepcopy(entry)
+    assert out.fields is not None
+    for key, value in out.fields.items():
         if key == "url":
             # The url can contain special LaTeX characters (like %) and that's fine
             continue
-        entry.fields[key] = translator.latex_to_text(value)
-    return entry
+        out.fields[key] = translator.latex_to_text(value)
+    return out
 
 
 def pybtex_to_dict(entry):
@@ -133,11 +137,12 @@ def create_dict():
 def _translate_word(word, d):
     # Check if the word needs to be protected by curly braces to prevent
     # recapitalization.
-    if not word:
-        needs_protection = False
-    elif word.count("{") != word.count("}"):
-        needs_protection = False
-    elif word[0] == "{" and word[-1] == "}":
+    if (
+        not word
+        or word.count("{") != word.count("}")
+        or (word[0] == "{" and word[-1] == "}")
+        or word[0] == "\\"
+    ):
         needs_protection = False
     elif any([char.isupper() for char in word[1:]]):
         needs_protection = True
@@ -181,41 +186,42 @@ def _translate_title(val, dictionary=create_dict()):
     return " ".join(words)
 
 
-def sanitize_title(d):
+def sanitize_title(d) -> None:
     for _, entry in d.items():
         try:
             title = entry.fields["title"]
             entry.fields["title"] = _translate_title(title)
         except KeyError:
             warn(f"'entry' {entry} has no title")
-    return d
 
 
 def pybtex_to_bibtex_string(
-    entry,
-    bibtex_key,
+    entry: Entry,
+    bibtex_key: str,
     delimiters: tuple[str, str] = ("{", "}"),
     indent: str = " ",
     sort: bool = False,
     unicode: bool = True,
-):
+) -> str:
     """String representation of BibTeX entry."""
     out = f"@{entry.type}{{{bibtex_key},\n{indent}"
     content = []
 
     left, right = delimiters
 
+    assert entry.persons is not None
     for key, persons in entry.persons.items():
         persons_str = " and ".join([_get_person_str(p) for p in persons])
         if not unicode:
             persons_str = unicode_to_latex(persons_str)
         content.append(f"{key.lower()} = {left}{persons_str}{right}")
 
+    assert entry.fields is not None
     keys = entry.fields.keys()
     if sort:
         keys = sorted(keys)
 
-    translator = LatexNodes2Text()
+    # translator = LatexNodes2Text()
 
     for key in keys:
         value = entry.fields[key]
@@ -228,9 +234,9 @@ def pybtex_to_bibtex_string(
         try:
             if key not in ["url", "doi"]:
                 # Parse the original value to get a unified version
-                value = translator.latex_to_text(value)
-                # back to latex to escape "&" etc.
-                value = unicode_to_latex(value)
+                # value = translator.latex_to_text(value)
+                # # back to latex to escape "&" etc.
+                # value = unicode_to_latex(value)
                 # Replace multiple spaces by one
                 value = re.sub(" +", " ", value)
                 # Remove trailing spaces
@@ -391,10 +397,10 @@ def heuristic_unique_result(results, d):
 # This used to be a write() function, but beware of exceptions! Files would get
 # unintentionally overridden, see <https://github.com/nschloe/betterbib/issues/184>
 def to_string(
-    od: dict[str, pybtex.database.Entry],
+    od: dict[str, Entry],
     delimiter_type: str,
     tab_indent: bool,
-    preamble: list = [],
+    preamble: list | None = None,
     unicode: bool = True,
 ) -> str:
     """


Test patch:
diff --git a/tests/cli/test_format_cli.py b/tests/cli/test_format_cli.py
index 5c1efeb..f4d2ba2 100644
--- a/tests/cli/test_format_cli.py
+++ b/tests/cli/test_format_cli.py
@@ -45,63 +45,6 @@
 @pytest.mark.parametrize(
     "ref_in,ref_out",
     [
-        (
-            "@article{foobar,\n"
-            "doi={foobar},\n"
-            "url = {https://doi.org/foobar}\n"
-            "}",
-            version_line + "\n"
-            "\n"
-            "@article{foobar,\n"
-            " doi = {foobar},\n"
-            " url = {https://doi.org/foobar},\n"
-            "}\n",
-        ),
-        # escape ampersand:
-        (
-            "@article{foobar,\n"
-            "doi={foobar},\n"
-            "url = {https://doi.org/foobar},\n"
-            "title = {Foo \\& Bar},\n"
-            "}",
-            version_line + "\n"
-            "\n"
-            "@article{foobar,\n"
-            " doi = {foobar},\n"
-            " url = {https://doi.org/foobar},\n"
-            r" title = {Foo \& Bar}," + "\n"
-            "}\n",
-        ),
-        # escape command:
-        # (
-        #     "@article{foobar,\n"
-        #     "doi={foobar},\n"
-        #     "url = {https://doi.org/foobar},\n"
-        #     r"title = {Foo on \LaTeX}," + "\n"
-        #     "}",
-        #     version_line + "\n"
-        #     "\n"
-        #     "@article{foobar,\n"
-        #     " doi = {foobar},\n"
-        #     " url = {https://doi.org/foobar},\n"
-        #     r" title = {Foo on \LaTeX}," + "\n"
-        #     "}\n",
-        # ),
-        # more escape:
-        (
-            "@article{foobar,\n"
-            "doi={foobar},\n"
-            "url = {https://doi.org/foobar},\n"
-            r"title = {Foo \& Bar on \ @TheBridge}," + "\n"
-            "}",
-            version_line + "\n"
-            "\n"
-            "@article{foobar,\n"
-            " doi = {foobar},\n"
-            " url = {https://doi.org/foobar},\n"
-            r" title = {Foo \& Bar on @TheBridge}," + "\n"
-            "}\n",
-        ),
         # Keeping when unformatted
         (
             TEST_BIBTEXT_PREAMBLE_UNFORMATTED,
diff --git a/tests/test_bibtex_title.py b/tests/test_bibtex_title.py
index 467c970..b709f76 100644
--- a/tests/test_bibtex_title.py
+++ b/tests/test_bibtex_title.py
@@ -41,6 +41,7 @@
             "{Aaa {${\\text{Pt/Co/AlO}}_{x}$} aaa bbb}",
         ),
         ("z*", "z*"),
+        ("A \\LaTeX title", "A \\LaTeX title"),
     ],
 )
 def test_translate_title(string, ref):
diff --git a/tests/test_to_string.py b/tests/test_to_string.py
new file mode 100644
index 0000000..d618048
--- /dev/null
+++ b/tests/test_to_string.py
@@ -0,0 +1,81 @@
+import pytest
+from pybtex.database import Entry, Person
+
+import betterbib
+
+
+@pytest.mark.parametrize(
+    "ref_entry,ref_str",
+    [
+        (
+            Entry(
+                "article",
+                fields={
+                    "doi": "foobar",
+                    "url": "https://doi.org/foobar",
+                },
+            ),
+            [
+                "@article{foobar,",
+                " doi = {foobar},",
+                " url = {https://doi.org/foobar},",
+                "}",
+            ],
+        ),
+        # escape ampersand:
+        (
+            Entry("article", fields={"title": "Foo \\& Bar"}),
+            ["@article{foobar,", " title = {Foo \\& Bar},", "}"],
+        ),
+        # escape command:
+        (
+            Entry("article", fields={"title": "Foo on \\LaTeX"}),
+            ["@article{foobar,", " title = {Foo on \\LaTeX},", "}"],
+        ),
+        # more empty space
+        (
+            Entry("article", fields={"title": "Foo \\ Bridge"}),
+            ["@article{foobar,", " title = {Foo \\ Bridge},", "}"],
+        ),
+        # encode url
+        (
+            Entry(
+                "misc",
+                fields={
+                    "url": "https://www.wolframalpha.com/input/?i=integrate+from+0+to+2pi+(cos(x)+e%5E(i+*+(m+-+n)+*+x))",
+                    "note": "Online; accessed 19-February-2019",
+                },
+            ),
+            [
+                "@misc{foobar,",
+                " url = {https://www.wolframalpha.com/input/?i=integrate+from+0+to+2pi+(cos(x)+e%5E(i+*+(m+-+n)+*+x))},",
+                " note = {Online; accessed 19-February-2019},",
+                "}",
+            ],
+        ),
+        (
+            Entry(
+                "misc",
+                fields={"doi": "10.1007/978-1-4615-7419-4_6"},
+            ),
+            [
+                "@misc{foobar,",
+                " doi = {10.1007/978-1-4615-7419-4_6},",
+                "}",
+            ],
+        ),
+        (
+            Entry(
+                "misc",
+                persons={"author": [Person("Doe, J. J.")]},
+            ),
+            [
+                "@misc{foobar,",
+                " author = {Doe, J. J.},",
+                "}",
+            ],
+        ),
+    ],
+)
+def test_cli_format(ref_entry, ref_str):
+    assert betterbib.pybtex_to_bibtex_string(ref_entry, "foobar") == "\n".join(ref_str)
diff --git a/tests/test_tools.py b/tests/test_tools.py
index f582696..827bace 100644
--- a/tests/test_tools.py
+++ b/tests/test_tools.py
@@ -80,62 +80,6 @@ def test_decode_doi():
     assert out.fields["doi"] == doi
 
 
-def test_encode_url():
-    url = "https://www.wolframalpha.com/input/?i=integrate+from+0+to+2pi+(cos(x)+e%5E(i+*+(m+-+n)+*+x))"
-    d = {
-        "wolframalphai1": pybtex.database.Entry(
-            "misc",
-            fields=[
-                ("url", url),
-                ("note", "Online; accessed 19-February-2019"),
-            ],
-        )
-    }
-
-    out = betterbib.pybtex_to_bibtex_string(d["wolframalphai1"], "wolframalphai1")
-
-    # Split string and fetch entry with url
-    url_entry = out.split()[3]
-    # Remove braces and trailing comma
-    url_entry = url_entry[1:-2]
-    assert url_entry == url
-
-
-def test_encode_doi():
-    doi = "10.1007/978-1-4615-7419-4_6"
-    d = {
-        "karwowski": pybtex.database.Entry(
-            "misc",
-            fields=[
-                ("doi", doi),
-                ("note", "Online; accessed 19-February-2019"),
-            ],
-        )
-    }
-    out = betterbib.pybtex_to_bibtex_string(d["karwowski"], "karwowski")
-
-    # Split string and fetch entry with doi
-    doi_entry = out.split()[3]
-    # Remove braces and trailing comma
-    doi_entry = doi_entry[1:-2]
-    assert doi_entry == doi
-
-
-def test_first_name_space():
-    d = {
-        "doe": pybtex.database.Entry(
-            "misc",
-            persons={"author": [pybtex.database.Person("Doe, J. J.")]},
-        )
-    }
-    out = betterbib.pybtex_to_bibtex_string(d["doe"], "doe")
-    ref = """@misc{doe,
- author = {Doe, J. J.},
-}"""
-
-    assert out == ref
-
-
 def test_create_citekey():
     entry = pybtex.database.Entry(
         "article",