Repository: texworld/betterbib (language: python)

Issue:
pre-commit support
I would like to add [pre-commit](pre-commit.com/) support to this tool (especially the `betterbib-format`-command) to support automatic formatting of `bib`-files in git repositories, if this is ok for the maintainers.

It would consist of adding a definition of the hook entry point and adding an 'inplace'-option to the cli call 
Show which file threw an error
Currently the `betterbib-format` tool raises an error from `pybtex`.

`pybtex.database.input.bibtex.DuplicateField: entry with key systematic-code has a duplicate X field`

This should be enhanced by adding the currently processed file

(Relevant for #191 )


Discussion / hints:
Sure.


Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..a77f4ce
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,18 @@
+---
+repos:
+- repo: https://github.com/pre-commit/pre-commit-hooks
+  rev: v4.0.1
+  hooks:
+    - id: check-yaml
+- repo: https://github.com/psf/black
+  rev: 21.6b0
+  hooks:
+    - id: black
+- repo: https://github.com/PyCQA/flake8
+  rev: 3.9.2
+  hooks:
+    - id: flake8
+- repo: https://github.com/PyCQA/isort
+  rev: 5.9.2
+  hooks:
+    - id: isort
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
new file mode 100644
index 0000000..fcedeb5
--- /dev/null
+++ b/.pre-commit-hooks.yaml
@@ -0,0 +1,9 @@
+---
+- id: betterbib-format
+  name: Format bib-files
+  description: Format bib-files in a consistent manner
+  entry: betterbib-format
+  language: python
+  files: '^.*\.bib$'
+  args:
+    - "--in-place"
diff --git a/pyproject.toml b/pyproject.toml
index 8fe2f47..3c2aae9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,6 @@
 [build-system]
 requires = ["setuptools>=42", "wheel"]
 build-backend = "setuptools.build_meta"
+
+[tool.isort]
+profile = "black"
diff --git a/src/betterbib/cli/dedup_doi.py b/src/betterbib/cli/dedup_doi.py
index 38a8deb..d591d03 100644
--- a/src/betterbib/cli/dedup_doi.py
+++ b/src/betterbib/cli/dedup_doi.py
@@ -1,24 +1,37 @@
 import argparse
-import sys
 
-from pybtex.database.input import bibtex
-
-from .. import tools
-from ..__about__ import __version__
+from ..tools import bibtex_parser, doi_from_url, to_string, write
+from .default_parser import (
+    get_file_parser_arguments,
+    get_formatting_parser_arguments,
+    get_version_parser_arguments,
+)
 
 
 def main(argv=None):
     parser = _get_parser()
     args = parser.parse_args(argv)
 
-    data = bibtex.Parser().parse_file(args.infile)
+    for infile in args.infiles:
+        _handle_single(args, infile)
+
+
+def _handle_single(args, infile):
+    """
+    Parse and handle a single file
+
+        Parameters:
+            args (dict): commandline arguments
+            infile (FileType("r")): file to be handled
+    """
+    data = bibtex_parser(infile)
 
     od = data.entries
 
     # deduplicate
     for key in data.entries:
         if "url" in od[key].fields and "doi" in od[key].fields:
-            doi = tools.doi_from_url(od[key].fields["url"])
+            doi = doi_from_url(od[key].fields["url"])
             if doi == od[key].fields["doi"]:
                 # Would be nicer to remove it completely; see
                 # <https://bitbucket.org/pybtex-devs/pybtex/issues/104/implement>.
@@ -27,18 +40,8 @@ def main(argv=None):
                 else:
                     od[key].fields["doi"] = None
 
-    _write(od, args.outfile, "curly")
-
-
-def _write(od, out, delimiter_type):
-    # Write header to the output file.
-    out.write(f"%comment{{This file was created with betterbib v{__version__}.}}\n\n")
-
-    # write the data out sequentially to respect ordering
-    for bib_id, d in od.items():
-        delimiters = ("{", "}") if delimiter_type == "curly" else ('"', '"')
-        a = tools.pybtex_to_bibtex_string(d, bib_id, delimiters=delimiters)
-        out.write(a + "\n\n")
+    string = to_string(od, args.delimiter_type, tab_indent=args.tab_indent)
+    write(string, infile if args.in_place else None)
 
 
 def _get_parser():
@@ -46,27 +49,11 @@ def _get_parser():
         description="Removes one of DOI and URL in a BibTeX file "
         "if both are identical."
     )
-    parser.add_argument(
-        "-v",
-        "--version",
-        help="display version information",
-        action="version",
-        version=f"betterbib {__version__}, Python {sys.version}",
-    )
-    parser.add_argument(
-        "infile",
-        nargs="?",
-        type=argparse.FileType("r"),
-        default=sys.stdin,
-        help="input BibTeX file (default: stdin)",
-    )
-    parser.add_argument(
-        "outfile",
-        nargs="?",
-        type=argparse.FileType("w"),
-        default=sys.stdout,
-        help="output BibTeX file (default: stdout)",
-    )
+
+    parser = get_version_parser_arguments(parser)
+    parser = get_file_parser_arguments(parser)
+    parser = get_formatting_parser_arguments(parser)
+
     parser.add_argument(
         "-k",
         "--keep-doi",
diff --git a/src/betterbib/cli/default_parser.py b/src/betterbib/cli/default_parser.py
new file mode 100644
index 0000000..831ed29
--- /dev/null
+++ b/src/betterbib/cli/default_parser.py
@@ -0,0 +1,93 @@
+import argparse
+import sys
+
+from ..__about__ import __version__
+
+
+def get_version_parser_arguments(parser):
+    """
+    Adds the version argument to an argparse parser
+
+        Parameters:
+            parser (argparse.ArgumentParser): ArgumentParser
+
+        Returns:
+            parser (argparse.ArgumentParser): Containing the version argument
+    """
+    parser.add_argument(
+        "-v",
+        "--version",
+        help="display version information",
+        action="version",
+        version=f"betterbib {__version__}, Python {sys.version}",
+    )
+
+    return parser
+
+
+def get_file_parser_arguments(parser):
+    """
+    Adds the file handling arguments to an argparse parser
+
+        Parameters:
+            parser (argparse.ArgumentParser): ArgumentParser
+
+        Returns:
+            parser (argparse.ArgumentParser): Containing the default file handling arguments
+    """
+    parser.add_argument(
+        "infiles",
+        nargs="+",
+        type=argparse.FileType("r"),
+        default=sys.stdin,
+        help="input BibTeX files (default: stdin)",
+    )
+    parser.add_argument(
+        "-i", "--in-place", action="store_true", help="modify infile in place"
+    )
+
+    return parser
+
+
+def get_formatting_parser_arguments(parser):
+    """
+    Adds the bibtex formatting arguments to an argparse parser
+
+        Parameters:
+            parser (argparse.ArgumentParser): ArgumentParser
+
+        Returns:
+            parser (argparse.ArgumentParser): Containing the formatting arguments
+    """
+    formatting_group = parser.add_argument_group("Formatting")
+    formatting_group.add_argument(
+        "-b",
+        "--sort-by-bibkey",
+        action="store_true",
+        help="sort entries by BibTeX key (default: false)",
+    )
+    formatting_group.add_argument(
+        "-t",
+        "--tab-indent",
+        action="store_true",
+        help="use tabs for indentation (default: false)",
+    )
+    formatting_group.add_argument(
+        "-d",
+        "--delimiter-type",
+        choices=["braces", "quotes"],
+        default="braces",
+        help=("which delimiters to use in the output file " "(default: braces {...})"),
+    )
+    formatting_group.add_argument(
+        "-u",
+        "--doi-url-type",
+        choices=["unchanged", "new", "short"],
+        default="new",
+        help=(
+            "DOI URL (new: https://doi.org/<DOI> (default), "
+            "short: https://doi.org/abcde)"
+        ),
+    )
+
+    return parser
diff --git a/src/betterbib/cli/doi2bibtex.py b/src/betterbib/cli/doi2bibtex.py
index 8d3c5ab..cb61c75 100644
--- a/src/betterbib/cli/doi2bibtex.py
+++ b/src/betterbib/cli/doi2bibtex.py
@@ -1,23 +1,13 @@
 import argparse
-import sys
 
 from unidecode import unidecode
 
-from .. import __about__, crossref, tools
-
-
-def _get_version_text():
-    return "\n".join(
-        [
-            "betterbib {} [Python {}.{}.{}]".format(
-                __about__.__version__,
-                sys.version_info.major,
-                sys.version_info.minor,
-                sys.version_info.micro,
-            ),
-            "Copyright (c) 2013-2020, Nico Schlömer <{nico.schloemer@gmail.com}>",
-        ]
-    )
+from .. import crossref
+from ..tools import to_string, write
+from .default_parser import (
+    get_formatting_parser_arguments,
+    get_version_parser_arguments,
+)
 
 
 def _create_citekey_for_entry(entry):
@@ -48,26 +38,20 @@ def main(argv=None):
     source = crossref.Crossref()
     entry = source.get_by_doi(args.doi)
     bibtex_citekey = _create_citekey_for_entry(entry)
-    string = tools.pybtex_to_bibtex_string(entry, bibtex_citekey)
-    args.outfile.write(string)
+
+    string = to_string(
+        {bibtex_citekey: entry}, args.delimiter_type, tab_indent=args.tab_indent
+    )
+
+    write(string)
     return
 
 
 def _get_parser():
     parser = argparse.ArgumentParser(description="Turn a DOI into a BibTeX entry.")
-    parser.add_argument(
-        "-v",
-        "--version",
-        help="display version information",
-        action="version",
-        version=_get_version_text(),
-    )
+
+    parser = get_version_parser_arguments(parser)
+    parser = get_formatting_parser_arguments(parser)
+
     parser.add_argument("doi", type=str, help="input DOI")
-    parser.add_argument(
-        "outfile",
-        nargs="?",
-        type=argparse.FileType("w"),
-        default=sys.stdout,
-        help="output file (default: stdout)",
-    )
     return parser
diff --git a/src/betterbib/cli/format.py b/src/betterbib/cli/format.py
index cf355c8..2cf80bd 100644
--- a/src/betterbib/cli/format.py
+++ b/src/betterbib/cli/format.py
@@ -1,21 +1,50 @@
 import argparse
-import sys
 
-from pybtex.database.input import bibtex
-
-from .. import tools
-from ..__about__ import __version__
 from ..adapt_doi_urls import adapt_doi_urls
+from ..tools import bibtex_parser, filter_fields, to_string, write
+from .default_parser import (
+    get_file_parser_arguments,
+    get_formatting_parser_arguments,
+    get_version_parser_arguments,
+)
 
 
 def main(argv=None):
     parser = _get_parser()
     args = parser.parse_args(argv)
 
-    data = bibtex.Parser().parse_file(args.infile)
+    for infile in args.infiles:
+        _handle_single(args, infile)
+
+
+def _handle_single(args, infile):
+    """
+    Parse and handle a single file
+
+        Parameters:
+            args (dict): commandline arguments
+            infile (FileType("r")): file to be handled
+    """
+    data = bibtex_parser(infile)
+
+    string = _format(args, data)
+
+    write(string, infile if args.in_place else None)
+
 
+def _format(args, data):
+    """
+    Formats a bibtex dictionary
+
+        Parameters:
+            args (dict): commandline arguments
+            data (dict): bibtex entries
+
+        Returns:
+            string (String): Formatted multiline string of the entries
+    """
     if args.drop:
-        data = tools.filter_fields(data, args.drop)
+        data = filter_fields(data, args.drop)
 
     # Use an ordered dictionary to make sure that the entries are written out
     # sorted by their BibTeX key if demanded.
@@ -27,64 +56,18 @@ def main(argv=None):
 
     d = adapt_doi_urls(d, args.doi_url_type)
 
-    args.outfile.write(
-        tools.to_string(d, args.delimiter_type, tab_indent=args.tab_indent)
-    )
+    string = to_string(d, args.delimiter_type, tab_indent=args.tab_indent)
+
+    return string
 
 
 def _get_parser():
     parser = argparse.ArgumentParser(description="Reformat BibTeX files.")
 
-    parser.add_argument(
-        "-v",
-        "--version",
-        help="display version information",
-        action="version",
-        version=f"betterbib {__version__}, Python {sys.version}",
-    )
-    parser.add_argument(
-        "infile",
-        nargs="?",
-        type=argparse.FileType("r"),
-        default=sys.stdin,
-        help="input BibTeX file (default: stdin)",
-    )
-    parser.add_argument(
-        "outfile",
-        nargs="?",
-        type=argparse.FileType("w"),
-        default=sys.stdout,
-        help="output BibTeX file (default: stdout)",
-    )
-    parser.add_argument(
-        "-b",
-        "--sort-by-bibkey",
-        action="store_true",
-        help="sort entries by BibTeX key (default: false)",
-    )
-    parser.add_argument(
-        "-t",
-        "--tab-indent",
-        action="store_true",
-        help="use tabs for indentation (default: false)",
-    )
-    parser.add_argument(
-        "-d",
-        "--delimiter-type",
-        choices=["braces", "quotes"],
-        default="braces",
-        help=("which delimiters to use in the output file " "(default: braces {...})"),
-    )
-    parser.add_argument(
-        "-u",
-        "--doi-url-type",
-        choices=["unchanged", "new", "short"],
-        default="new",
-        help=(
-            "DOI URL (new: https://doi.org/<DOI> (default), "
-            "short: https://doi.org/abcde)"
-        ),
-    )
+    parser = get_version_parser_arguments(parser)
+    parser = get_file_parser_arguments(parser)
+    parser = get_formatting_parser_arguments(parser)
+
     parser.add_argument(
         "--drop",
         nargs="+",
diff --git a/src/betterbib/cli/full.py b/src/betterbib/cli/full.py
index 1e42eda..cf3bf4d 100644
--- a/src/betterbib/cli/full.py
+++ b/src/betterbib/cli/full.py
@@ -1,68 +1,65 @@
 import argparse
-import sys
 
-from pybtex.database.input import bibtex
-
-from .. import __about__, tools
 from ..adapt_doi_urls import adapt_doi_urls
 from ..journal_abbrev import journal_abbrev
 from ..sync import sync
+from ..tools import bibtex_parser, sanitize_title, to_string, write
+from .default_parser import (
+    get_file_parser_arguments,
+    get_formatting_parser_arguments,
+    get_version_parser_arguments,
+)
 
 
 def main(argv=None):
     parser = _get_parser()
     args = parser.parse_args(argv)
 
+    for infile in args.infiles:
+        _handle_single(args, infile)
+
+
+def _handle_single(args, infile):
+    """
+    Parse and handle a single file
+
+        Parameters:
+            args (dict): commandline arguments
+            infile (FileType("r")): file to be handled
+    """
     # Make sure that the entries are written out sorted by their BibTeX key if demanded.
-    data = bibtex.Parser().parse_file(args.infile)
+    data = bibtex_parser(infile)
     tuples = data.entries.items()
     if args.sort_by_bibkey:
         tuples = sorted(data.entries.items())
 
     d = dict(tuples)
 
-    d = sync(d, args.source, args.long_journal_names, args.num_concurrent_requests)
+    d = sync(
+        d,
+        args.source,
+        args.long_journal_names,
+        args.num_concurrent_requests,
+        args.in_place,
+    )
     d = adapt_doi_urls(d, args.doi_url_type)
-    d = tools.sanitize_title(d)
+    d = sanitize_title(d)
     d = journal_abbrev(d, args.long_journal_names, args.extra_abbrev_file)
 
-    string = tools.to_string(d, args.delimiter_type, tab_indent=args.tab_indent)
+    string = to_string(d, args.delimiter_type, tab_indent=args.tab_indent)
 
-    if args.in_place:
-        with open(args.infile.name, "w") as f:
-            f.write(string)
-    else:
-        args.outfile.write(string)
+    write(string, infile if args.in_place else None)
 
 
 def _get_parser():
     parser = argparse.ArgumentParser(
         description="Sync BibTeX files with information from online sources."
     )
-    parser.add_argument(
-        "-v",
-        "--version",
-        help="display version information",
-        action="version",
-        version=f"betterbib {__about__.__version__}, Python {sys.version}",
-    )
-    parser.add_argument(
-        "infile",
-        nargs="?",
-        type=argparse.FileType("r"),
-        default=sys.stdin,
-        help="input BibTeX file (default: stdin)",
-    )
-    parser.add_argument(
-        "outfile",
-        nargs="?",
-        type=argparse.FileType("w"),
-        default=sys.stdout,
-        help="output BibTeX file (default: stdout)",
-    )
-    parser.add_argument(
-        "-i", "--in-place", action="store_true", help="modify infile in place"
-    )
+
+    parser = get_version_parser_arguments(parser)
+    parser = get_file_parser_arguments(parser)
+    parser = get_formatting_parser_arguments(parser)
+
     parser.add_argument(
         "-s",
         "--source",
@@ -90,33 +87,4 @@ def _get_parser():
         metavar="N",
         help="number of concurrent HTTPS requests (default: 10)",
     )
-    parser.add_argument(
-        "-b",
-        "--sort-by-bibkey",
-        action="store_true",
-        help="sort entries by BibTeX key (default: false)",
-    )
-    parser.add_argument(
-        "-t",
-        "--tab-indent",
-        action="store_true",
-        help="use tabs for indentation (default: false, use spaces)",
-    )
-    parser.add_argument(
-        "-d",
-        "--delimiter-type",
-        choices=["braces", "quotes"],
-        default="braces",
-        help=("which delimiters to use in the output file " "(default: braces {...})"),
-    )
-    parser.add_argument(
-        "-u",
-        "--doi-url-type",
-        choices=["unchanged", "new", "short"],
-        default="new",
-        help=(
-            "DOI URL (new: https://doi.org/<DOI> (default), "
-            "short: https://doi.org/abcde)"
-        ),
-    )
     return parser
diff --git a/src/betterbib/cli/journal_abbrev.py b/src/betterbib/cli/journal_abbrev.py
index 58b6529..a58b377 100644
--- a/src/betterbib/cli/journal_abbrev.py
+++ b/src/betterbib/cli/journal_abbrev.py
@@ -1,47 +1,47 @@
 import argparse
-import sys
 
-from pybtex.database.input import bibtex
-
-from .. import __about__, tools
 from ..journal_abbrev import journal_abbrev
+from ..tools import bibtex_parser, to_string, write
+from .default_parser import (
+    get_file_parser_arguments,
+    get_formatting_parser_arguments,
+    get_version_parser_arguments,
+)
 
 
 def main(argv=None):
     parser = _get_parser()
     args = parser.parse_args(argv)
 
-    data = bibtex.Parser().parse_file(args.infile)
+    for infile in args.infiles:
+        _handle_single(args, infile)
+
+
+def _handle_single(args, infile):
+    """
+    Parse and handle a single file
+
+        Parameters:
+            args (dict): commandline arguments
+            infile (FileType("r")): file to be handled
+    """
+    data = bibtex_parser(infile)
     d = dict(data.entries.items())
 
     d = journal_abbrev(d, args.long_journal_names, args.extra_abbrev_file)
 
-    args.outfile.write(tools.to_string(d, "braces", tab_indent=False))
+    string = to_string(d, "braces", tab_indent=False)
+
+    write(string, infile if args.in_place else None)
 
 
 def _get_parser():
     parser = argparse.ArgumentParser(description="(Un)abbreviate journal names.")
-    parser.add_argument(
-        "-v",
-        "--version",
-        help="display version information",
-        action="version",
-        version=f"betterbib {__about__.__version__}, Python {sys.version}",
-    )
-    parser.add_argument(
-        "infile",
-        nargs="?",
-        type=argparse.FileType("r"),
-        default=sys.stdin,
-        help="input BibTeX file (default: stdin)",
-    )
-    parser.add_argument(
-        "outfile",
-        nargs="?",
-        type=argparse.FileType("w"),
-        default=sys.stdout,
-        help="output BibTeX file (default: stdout)",
-    )
+
+    parser = get_version_parser_arguments(parser)
+    parser = get_file_parser_arguments(parser)
+    parser = get_formatting_parser_arguments(parser)
+
     parser.add_argument(
         "-l",
         "--long-journal-names",
diff --git a/src/betterbib/cli/sync.py b/src/betterbib/cli/sync.py
index d67973a..b1db079 100644
--- a/src/betterbib/cli/sync.py
+++ b/src/betterbib/cli/sync.py
@@ -1,53 +1,56 @@
 import argparse
-import sys
 
-from pybtex.database.input import bibtex
-
-from .. import __about__
 from ..sync import sync
-from ..tools import to_string
+from ..tools import bibtex_parser, to_string, write
+from .default_parser import (
+    get_file_parser_arguments,
+    get_formatting_parser_arguments,
+    get_version_parser_arguments,
+)
 
 
 def main(argv=None):
     parser = _get_parser()
     args = parser.parse_args(argv)
 
-    data = bibtex.Parser().parse_file(args.infile)
+    for infile in args.infiles:
+        _handle_single(args, infile)
+
+
+def _handle_single(args, infile):
+    """
+    Parse and handle a single file
+
+        Parameters:
+            args (dict): commandline arguments
+            infile (FileType("r")): file to be handled
+    """
+    data = bibtex_parser(infile)
     # data.entries.items() is a list of tuples, the first item being the BibTeX key.
     input_dict = dict(data.entries.items())
 
     out = sync(
-        input_dict, args.source, args.long_journal_name, args.num_concurrent_requests
+        input_dict,
+        args.source,
+        args.long_journal_name,
+        args.num_concurrent_requests,
+        args.in_place,
     )
 
-    args.outfile.write(to_string(out, "braces", tab_indent=False))
+    string = to_string(out, args.delimiter_type, tab_indent=args.tab_indent)
+
+    write(string, infile if args.in_place else None)
 
 
 def _get_parser():
     parser = argparse.ArgumentParser(
         description="Sync BibTeX files with information from online sources."
     )
-    parser.add_argument(
-        "-v",
-        "--version",
-        help="display version information",
-        action="version",
-        version=f"betterbib {__about__.__version__}, Python {sys.version}",
-    )
-    parser.add_argument(
-        "infile",
-        nargs="?",
-        type=argparse.FileType("r"),
-        default=sys.stdin,
-        help="input BibTeX file (default: stdin)",
-    )
-    parser.add_argument(
-        "outfile",
-        nargs="?",
-        type=argparse.FileType("w"),
-        default=sys.stdout,
-        help="output BibTeX file (default: stdout)",
-    )
+
+    parser = get_version_parser_arguments(parser)
+    parser = get_file_parser_arguments(parser)
+    parser = get_formatting_parser_arguments(parser)
+
     parser.add_argument(
         "-s",
         "--source",
diff --git a/src/betterbib/sync.py b/src/betterbib/sync.py
index 6c72761..4fdf7f8 100644
--- a/src/betterbib/sync.py
+++ b/src/betterbib/sync.py
@@ -5,7 +5,23 @@
 from . import crossref, dblp, errors, tools
 
 
-def sync(d, source, long_journal_name, max_workers):
+def sync(
+    d: dict, source: str, long_journal_name: bool, max_workers: int, verbose: bool
+):
+    """
+    Sync a bibtex dict with an external source
+
+        Parameters:
+            d (dict): bibtex dict
+            source (str): data source to sync against
+            long_journal_name (bool): use the long journal name instead of short
+            max_workers (int): number of concurrent workers to use
+            verbose (bool): print additional information to stdout
+
+        Returns:
+            synced dict
+    """
+
     if source == "crossref":
         source = crossref.Crossref(long_journal_name)
     else:
@@ -22,6 +38,7 @@ def sync(d, source, long_journal_name, max_workers):
             concurrent.futures.as_completed(responses),
             total=len(responses),
             description="Syncing...",
+            disable=not verbose,
         ):
             bib_id, entry = responses[future]
             try:
@@ -33,7 +50,8 @@ def sync(d, source, long_journal_name, max_workers):
             else:
                 num_success += 1
                 d[bib_id] = tools.update(entry, data)
+    if verbose:
+        print(f"\n\nTotal number of entries: {len(d)}")
+        print(f"Found: {num_success}")
 
-    print(f"\n\nTotal number of entries: {len(d)}")
-    print(f"Found: {num_success}")
     return d
diff --git a/src/betterbib/tools.py b/src/betterbib/tools.py
index 54bd805..43d1d11 100644
--- a/src/betterbib/tools.py
+++ b/src/betterbib/tools.py
@@ -2,6 +2,9 @@
 import configparser
 import os
 import re
+
+# needed for the bibtex_writer
+import sys
 from typing import Tuple
 
 import appdirs
@@ -11,6 +14,9 @@
 import latexcodec  # noqa
 import requests
 
+# for enhanced error messages when parsing
+from pybtex.database.input import bibtex
+
 from .__about__ import __version__
 from .errors import UniqueError
 
@@ -442,3 +448,37 @@ def filter_fields(data, excludes=None):
         if entry.fields:
             entry.fields = {k: v for k, v in entry.fields.items() if k not in excludes}
     return data
+
+
+def bibtex_parser(infile):
+    """
+    Returns the parsed bibtex data and adds context to the exception
+
+        Parameters:
+            infile (FileType("r")): file to be parsed
+
+        Returns:
+            data (dict): bibtex entries
+    """
+    try:
+        data = bibtex.Parser().parse_file(infile)
+        return data
+
+    except Exception as e:
+        print("There was an error when parsing " + infile.name)
+        raise e
+
+
+def write(string: str, outfile=None):
+    """
+    Writes a string to a BibTeX file
+
+        Parameters:
+            string (String): string to write
+            outfile (FileType("r")): file to write to (default: None)
+    """
+    if outfile:
+        with open(outfile.name, "w") as f:
+            f.write(string)
+    else:
+        sys.stdout.write(string)


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 62f156d..6f8a96c 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -14,23 +14,20 @@ def test_cli_dedup_doi():
             "}"
         )
 
-    outfile = tempfile.NamedTemporaryFile().name
-
-    betterbib.cli.dedup_doi([infile, outfile])
-    with open(outfile) as f:
+    betterbib.cli.dedup_doi(["--in-place", infile])
+    with open(infile) as f:
         assert f.read() == (
             f"%comment{{This file was created with betterbib v{betterbib.__version__}.}}\n"
             "\n"
+            "\n"
             "@article{foobar,\n"
             " url = {https://doi.org/foobar},\n"
             "}\n"
-            "\n"
         )
     os.remove(infile)
-    os.remove(outfile)
 
 
-def test_cli_format():
+def test_cli_dedup_doi_stdout(capsys):
     infile = tempfile.NamedTemporaryFile().name
     with open(infile, "w") as f:
         f.write(
@@ -40,11 +37,33 @@ def test_cli_format():
             "}"
         )
 
-    outfile = tempfile.NamedTemporaryFile().name
+    betterbib.cli.dedup_doi([infile])
+    captured = capsys.readouterr()
+
+    assert captured.out == "".join(
+        f"%comment{{This file was created with betterbib v{betterbib.__version__}.}}\n"
+        "\n"
+        "\n"
+        "@article{foobar,\n"
+        " url = {https://doi.org/foobar},\n"
+        "}\n"
+    )
+    os.remove(infile)
+
+
+def test_cli_format():
+    infile = tempfile.NamedTemporaryFile().name
+    with open(infile, "w") as f:
+        f.write(
+            "@article{foobar,\n"
+            "doi={foobar},\n"
+            "url = {https://doi.org/foobar}\n"
+            "}"
+        )
 
-    betterbib.cli.format([infile, outfile])
+    betterbib.cli.format(["--in-place", infile])
 
-    with open(outfile) as f:
+    with open(infile) as f:
         assert f.read() == (
             f"%comment{{This file was created with betterbib v{betterbib.__version__}.}}\n"
             "\n"
@@ -56,7 +75,32 @@ def test_cli_format():
         )
 
     os.remove(infile)
-    os.remove(outfile)
+
+
+def test_cli_format_stdout(capsys):
+    infile = tempfile.NamedTemporaryFile().name
+    with open(infile, "w") as f:
+        f.write(
+            "@article{foobar,\n"
+            "doi={foobar},\n"
+            "url = {https://doi.org/foobar}\n"
+            "}"
+        )
+
+    betterbib.cli.format([infile])
+    captured = capsys.readouterr()
+
+    assert captured.out == "".join(
+        f"%comment{{This file was created with betterbib v{betterbib.__version__}.}}\n"
+        "\n"
+        "\n"
+        "@article{foobar,\n"
+        " doi = {foobar},\n"
+        " url = {https://doi.org/foobar},\n"
+        "}\n"
+    )
+
+    os.remove(infile)
 
 
 def test_cli_journal_abbrev():
@@ -64,11 +108,9 @@ def test_cli_journal_abbrev():
     with open(infile, "w") as f:
         f.write("@article{foobar,\njournal={SIAM Journal on Scientific Computing}\n}")
 
-    outfile = tempfile.NamedTemporaryFile().name
+    betterbib.cli.journal_abbrev(["--in-place", infile])
 
-    betterbib.cli.journal_abbrev([infile, outfile])
-
-    with open(outfile) as f:
+    with open(infile) as f:
         assert f.read() == (
             f"%comment{{This file was created with betterbib v{betterbib.__version__}.}}\n"
             "\n"
@@ -79,7 +121,26 @@ def test_cli_journal_abbrev():
         )
 
     os.remove(infile)
-    os.remove(outfile)
+
+
+def test_cli_journal_abbrev_stdout(capsys):
+    infile = tempfile.NamedTemporaryFile().name
+    with open(infile, "w") as f:
+        f.write("@article{foobar,\njournal={SIAM Journal on Scientific Computing}\n}")
+
+    betterbib.cli.journal_abbrev([infile])
+    captured = capsys.readouterr()
+
+    assert captured.out == "".join(
+        f"%comment{{This file was created with betterbib v{betterbib.__version__}.}}\n"
+        "\n"
+        "\n"
+        "@article{foobar,\n"
+        " journal = {SIAM J. Sci. Comput.},\n"
+        "}\n"
+    )
+
+    os.remove(infile)
 
 
 def test_cli_sync():
@@ -92,11 +153,9 @@ def test_cli_sync():
             "}"
         )
 
-    outfile = tempfile.NamedTemporaryFile().name
-
-    betterbib.cli.sync([infile, outfile])
+    betterbib.cli.sync(["--in-place", infile])
 
-    with open(outfile) as f:
+    with open(infile) as f:
         assert f.read() == (
             f"%comment{{This file was created with betterbib v{betterbib.__version__}.}}\n"
             "\n"
@@ -117,30 +176,65 @@ def test_cli_sync():
             "}\n"
         )
     os.remove(infile)
-    os.remove(outfile)
 
 
-def test_cli_doi2bibtex():
-    outfile = tempfile.NamedTemporaryFile().name
-    betterbib.cli.doi2bibtex(["10.1016/s0084-3954(09)79550-8", outfile])
-    with open(outfile) as f:
-        assert f.read() == (
-            "@article{stockman2011,\n"
-            " author = {Stockman, J.A.},\n"
-            " doi = {10.1016/s0084-3954(09)79550-8},\n"
-            " pages = {193-194},\n"
-            " source = {Crossref},\n"
-            " url = {http://dx.doi.org/10.1016/s0084-3954(09)79550-8},\n"
-            " volume = {2011},\n"
-            " journal = {Yearbook of Pediatrics},\n"
-            " publisher = {Elsevier BV},\n"
-            " title = {A New Equation to Estimate Glomerular Filtration Rate},\n"
-            " issn = {0084-3954},\n"
-            " year = {2011},\n"
-            " month = jan,\n"
+def test_cli_sync_stdout(capsys):
+    infile = tempfile.NamedTemporaryFile().name
+    with open(infile, "w") as f:
+        f.write(
+            "@article{stockman,\n"
+            " author = {Stockman},\n"
+            "title = {A New Equation to Estimate Glomerular Filtration Rate}\n"
             "}"
         )
-    os.remove(outfile)
+
+    betterbib.cli.sync([infile])
+    captured = capsys.readouterr()
+
+    assert captured.out == "".join(
+        f"%comment{{This file was created with betterbib v{betterbib.__version__}.}}\n"
+        "\n"
+        "\n"
+        "@article{stockman,\n"
+        " author = {Stockman, J.A.},\n"
+        " title = {A New Equation to Estimate Glomerular Filtration Rate},\n"
+        " doi = {10.1016/s0084-3954(09)79550-8},\n"
+        " pages = {193-194},\n"
+        " source = {Crossref},\n"
+        " url = {http://dx.doi.org/10.1016/s0084-3954(09)79550-8},\n"
+        " volume = {2011},\n"
+        " journal = {Yearbook of Pediatrics},\n"
+        " publisher = {Elsevier BV},\n"
+        " issn = {0084-3954},\n"
+        " year = {2011},\n"
+        " month = jan,\n"
+        "}\n"
+    )
+    os.remove(infile)
+
+
+def test_cli_doi2bibtex(capsys):
+    betterbib.cli.doi2bibtex(["10.1016/s0084-3954(09)79550-8"])
+    captured = capsys.readouterr()
+    assert captured.out == "".join(
+        f"%comment{{This file was created with betterbib v{betterbib.__version__}.}}\n"
+        "\n"
+        "\n"
+        "@article{stockman2011,\n"
+        " author = {Stockman, J.A.},\n"
+        " doi = {10.1016/s0084-3954(09)79550-8},\n"
+        " pages = {193-194},\n"
+        " source = {Crossref},\n"
+        " url = {http://dx.doi.org/10.1016/s0084-3954(09)79550-8},\n"
+        " volume = {2011},\n"
+        " journal = {Yearbook of Pediatrics},\n"
+        " publisher = {Elsevier BV},\n"
+        " title = {A New Equation to Estimate Glomerular Filtration Rate},\n"
+        " issn = {0084-3954},\n"
+        " year = {2011},\n"
+        " month = jan,\n"
+        "}\n"
+    )
 
 
 def test_cli_full():
@@ -153,9 +247,7 @@ def test_cli_full():
             "}"
         )
 
-    outfile = tempfile.NamedTemporaryFile().name
-
-    betterbib.cli.full([infile, outfile])
+    betterbib.cli.full(["--in-place", infile])
 
     ref = (
         f"%comment{{This file was created with betterbib v{betterbib.__version__}.}}\n"
@@ -177,9 +269,46 @@ def test_cli_full():
         "}\n"
     )
 
-    with open(outfile) as f:
+    with open(infile) as f:
         data = f.read()
         assert data == ref
 
     os.remove(infile)
-    os.remove(outfile)
+
+
+def test_cli_full_stdout(capsys):
+    infile = tempfile.NamedTemporaryFile().name
+    with open(infile, "w") as f:
+        f.write(
+            "@article{stockman,\n"
+            " author = {Stockman},\n"
+            "title = {A New Equation to Estimate Glomerular Filtration Rate}\n"
+            "}"
+        )
+
+    betterbib.cli.full([infile])
+    captured = capsys.readouterr()
+
+    ref = (
+        f"%comment{{This file was created with betterbib v{betterbib.__version__}.}}\n"
+        "\n"
+        "\n"
+        "@article{stockman,\n"
+        " author = {Stockman, J.A.},\n"
+        " title = {A New Equation to Estimate Glomerular Filtration Rate},\n"
+        " doi = {10.1016/s0084-3954(09)79550-8},\n"
+        " pages = {193-194},\n"
+        " source = {Crossref},\n"
+        " url = {https://doi.org/10.1016/s0084-3954(09)79550-8},\n"
+        " volume = {2011},\n"
+        " journal = {Yearbook of Pediatrics},\n"
+        " publisher = {Elsevier BV},\n"
+        " issn = {0084-3954},\n"
+        " year = {2011},\n"
+        " month = jan,\n"
+        "}\n"
+    )
+
+    assert captured.out == "".join(ref)
+
+    os.remove(infile)