diff --git a/bibtexparser/middlewares/enclosing.py b/bibtexparser/middlewares/enclosing.py index a3c09cad..e35b11ca 100644 --- a/bibtexparser/middlewares/enclosing.py +++ b/bibtexparser/middlewares/enclosing.py @@ -148,7 +148,12 @@ def _enclose( if demanded_enclosing is not None: enclosing = demanded_enclosing elif self._reuse_previous_enclosing and metadata_enclosing is not None: - enclosing = metadata_enclosing + # Parser metadata describes the source value, not necessarily its + # current edited value. Reusing a bare integer's metadata for new + # ordinary text would emit invalid BibTeX, so fall back to the + # configured default unless the bare representation remains safe. + if metadata_enclosing != "no-enclosing" or str(value).isdigit(): + enclosing = metadata_enclosing elif apply_int_rule and not self._enclose_integers and str(value).isdigit(): return str(value) diff --git a/bibtexparser/middlewares/parsestack.py b/bibtexparser/middlewares/parsestack.py index 8bbe789c..85864846 100644 --- a/bibtexparser/middlewares/parsestack.py +++ b/bibtexparser/middlewares/parsestack.py @@ -19,7 +19,7 @@ def default_unparse_stack(allow_inplace_modification: bool = False) -> list[Midd AddEnclosingMiddleware( allow_inplace_modification=allow_inplace_modification, default_enclosing="{", - reuse_previous_enclosing=False, + reuse_previous_enclosing=True, enclose_integers=True, ) ] diff --git a/docs/source/customize.rst b/docs/source/customize.rst index 9b8a2ae4..43e06e5b 100644 --- a/docs/source/customize.rst +++ b/docs/source/customize.rst @@ -55,6 +55,13 @@ The default write stack consists of the following layers: * :class:`bibtexparser.middlewares.AddEnclosingMiddleware`: Encloses values in curly braces where needed. +For parsed values, the default writer reuses the quote, brace, or safe +unenclosed style recorded by +:class:`bibtexparser.middlewares.RemoveEnclosingMiddleware`. New programmatic +values without prior enclosure metadata use curly braces. If a formerly bare +integer is edited to ordinary text, the writer also falls back to braces rather +than emitting invalid bare text. + When specifying their own stack, user get to chose if they want to add to or overwrite the default stack by selecting the corresponding argument when calling :code:`bibtexparser.parse` or :code:`bibtexparser.write`: diff --git a/tests/middleware_tests/test_enclosing.py b/tests/middleware_tests/test_enclosing.py index e5e021f8..0871617e 100644 --- a/tests/middleware_tests/test_enclosing.py +++ b/tests/middleware_tests/test_enclosing.py @@ -166,7 +166,10 @@ def test_addition_of_enclosing_on_entry( used_enclosing = _figure_out_added_enclosing(changed_value, value) # Assert correct enclosing was added - if reuse_previous_enclosing and metadata_enclosing is not None: + can_safely_reuse_metadata = metadata_enclosing != "no-enclosing" or ( + isinstance(value, int) or value.isdigit() + ) + if reuse_previous_enclosing and metadata_enclosing is not None and can_safely_reuse_metadata: expected_enclosing = metadata_enclosing elif (isinstance(value, int) or value.isdigit()) and not enclose_ints: expected_enclosing = "no-enclosing" @@ -417,8 +420,53 @@ def test_string_reference_roundtrip(): assert "month = jan" in written assert 'pages = intro # "--" # outro' in written - # Ints are still enclosed by the default unparse stack - assert "year = {2019}" in written + # The default unparse stack also retains the integer's bare source style. + assert "year = 2019" in written + + +def test_default_roundtrip_reuses_parsed_value_enclosures(): + """Ordinary writing retains quote, brace, and safe bare source choices.""" + library = bibtexparser.parse_string( + '@article{key, quoted="Quoted", braced={Braced}, year=2019}' + ) + + written = bibtexparser.write_string(library) + + assert 'quoted = "Quoted"' in written + assert "braced = {Braced}" in written + assert "year = 2019" in written + + +def test_edit_reuses_quoted_and_braced_enclosures(): + """Changing a value must not also replace its established safe enclosure style.""" + library = bibtexparser.parse_string('@article{key, quoted="Before", braced={Before}}') + library.entries[0]["quoted"] = "After" + library.entries[0]["braced"] = "After" + + written = bibtexparser.write_string(library) + + assert 'quoted = "After"' in written + assert "braced = {After}" in written + + +def test_editing_bare_integer_to_text_falls_back_to_braces(): + """Stale bare metadata cannot make an edited ordinary string invalid BibTeX.""" + library = bibtexparser.parse_string("@article{key, year=2019}") + library.entries[0]["year"] = "forthcoming soon" + + written = bibtexparser.write_string(library) + + assert "year = {forthcoming soon}" in written + + +def test_new_field_without_enclosure_metadata_uses_braces(): + """Source-style reuse does not change the safe default for programmatic values.""" + library = bibtexparser.parse_string('@article{key, title="Existing"}') + library.entries[0]["note"] = "New value" + + written = bibtexparser.write_string(library) + + assert "note = {New value}" in written # TODO round-trip tests (removal -> addition -> removal)