diff --git a/bibtexparser/entrypoint.py b/bibtexparser/entrypoint.py index 68d9e107..6b097378 100644 --- a/bibtexparser/entrypoint.py +++ b/bibtexparser/entrypoint.py @@ -310,6 +310,10 @@ def write_file( ) -> None: """Write a BibTeX database to a file. + The passed library is never modified, unless *every* middleware in the + unparse stack allows in-place modification (e.g. + ``unparse_stack=default_unparse_stack(allow_inplace_modification=True)``). + :param file: File to write to. Can be a file name or a file object. :param library: BibTeX database to serialize. :param unparse_stack: List of middleware to apply to the database before writing. @@ -352,6 +356,10 @@ def write_string( ) -> str: """Serialize a BibTeX database to a string. + The passed library is never modified, unless *every* middleware in the + unparse stack allows in-place modification (e.g. + ``unparse_stack=default_unparse_stack(allow_inplace_modification=True)``). + :param library: BibTeX database to serialize. :param unparse_stack: List of middleware to apply to the database before writing. If None, a default stack will be used. @@ -372,6 +380,12 @@ def write_string( stack = _build_unparse_stack(unparse_stack, prepend_middleware) _warn_if_large_library_is_copied(library, stack) + inplace = [middleware.allow_inplace_modification for middleware in stack] + if any(inplace) and not all(inplace): + # Some middleware would mutate the passed library before a copying + # middleware gets to run; copy once upfront so the caller's library + # stays untouched (an all-in-place stack is the caller's explicit opt-in). + library = deepcopy(library) middleware: Middleware for middleware in stack: diff --git a/docs/source/customize.rst b/docs/source/customize.rst index 9b8a2ae4..7907ad36 100644 --- a/docs/source/customize.rst +++ b/docs/source/customize.rst @@ -272,6 +272,11 @@ bibtex string references and concatenation expressions, such as month macros: # hence the written entry contains `month = jan` (a string reference) # and not `month = {jan}` (a literal). +Note that ``write_string`` / ``write_file`` never modify the passed library +(``library.entries[0]["month"]`` is still ``"1"`` above), unless *every* middleware +in the unparse stack allows in-place modification, e.g. +``unparse_stack=default_unparse_stack(allow_inplace_modification=True)``. + You may also set the demand manually, e.g. ``entry.fields_dict["title"].enclosing = '"'`` to enforce quotes for a specific field. Allowed values are ``'{'``, ``'"'`` and ``'no-enclosing'``; ``None`` (the default) leaves the choice to the middleware. diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index c8a46ab9..6da14484 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -7,12 +7,14 @@ import pytest +from bibtexparser import entrypoint from bibtexparser import parse_file from bibtexparser import parse_string from bibtexparser import write_file from bibtexparser import write_string from bibtexparser.entrypoint import LARGE_LIBRARY_WARNING_THRESHOLD from bibtexparser.library import Library +from bibtexparser.middlewares import MonthAbbreviationMiddleware from bibtexparser.middlewares import default_unparse_stack from bibtexparser.model import DuplicateBlockKeyBlock from bibtexparser.model import Entry @@ -471,3 +473,59 @@ def test_write_string_does_not_warn_with_inplace_stack(monkeypatch, caplog): assert "Writing a library" not in caplog.text assert actual == expected + + +MONTH_BIBTEX = "@article{a, month = {January}, title = {T}}" + + +def test_write_string_with_inplace_prepend_middleware_does_not_mutate_library(): + """An in-place middleware prepended to the (copying) default stack must not + leak its modifications into the caller's library.""" + library = parse_string(MONTH_BIBTEX) + + bib_str = write_string(library, prepend_middleware=[MonthAbbreviationMiddleware()]) + + assert "month = jan" in bib_str + assert library.entries[0]["month"] == "January" + + +def test_write_file_with_inplace_prepend_middleware_does_not_mutate_library(tmp_path): + library = parse_string(MONTH_BIBTEX) + path = tmp_path / "out.bib" + + write_file(str(path), library, prepend_middleware=[MonthAbbreviationMiddleware()]) + + assert "month = jan" in path.read_text(encoding="UTF-8") + assert library.entries[0]["month"] == "January" + + +def test_write_string_default_stack_does_not_mutate_library(): + library = parse_string(MONTH_BIBTEX) + + write_string(library) + + # An in-place AddEnclosingMiddleware would have turned this into "{January}". + assert library.entries[0]["month"] == "January" + assert library.entries[0]["title"] == "T" + + +def test_write_string_all_inplace_stack_may_mutate_library(): + """A stack in which every middleware allows in-place modification is the + caller's explicit opt-in to skip copying.""" + library = parse_string(MONTH_BIBTEX) + stack = [MonthAbbreviationMiddleware()] + default_unparse_stack(allow_inplace_modification=True) + + write_string(library, unparse_stack=stack) + + assert library.entries[0]["month"] == "jan" + + +def test_write_string_copying_stack_gets_no_upfront_copy(monkeypatch): + """No extra upfront copy if every middleware already copies on its own.""" + library = parse_string(MONTH_BIBTEX) + monkeypatch.setattr( + entrypoint, "deepcopy", lambda *args, **kwargs: pytest.fail("unexpected upfront copy") + ) + + write_string(library, unparse_stack=default_unparse_stack(allow_inplace_modification=False)) + write_string(library)