Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions bibtexparser/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions docs/source/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions tests/test_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading