From aab6fe17056bc97eb505b0eb7dd5d74252ee8b49 Mon Sep 17 00:00:00 2001 From: Michael Weiss Date: Fri, 12 Jun 2026 07:36:50 +0200 Subject: [PATCH 1/5] :sparkles: Add `enclosing` demand to `Field` and `String` A non-None `enclosing` ('{', '"' or 'no-enclosing') on a Field or String is now honored by the `AddEnclosingMiddleware` with precedence over all other enclosing rules. Assigning a new value resets the demand, such that stale demands cannot be applied to changed values. Also makes `AddEnclosingMiddleware._enclose` safe for int values (previously, `value.isdigit()` raised AttributeError for int values when `enclose_integers=False`). Towards #447 Co-Authored-By: Claude Fable 5 --- bibtexparser/middlewares/enclosing.py | 38 +++++++++-- bibtexparser/model.py | 64 ++++++++++++++++- tests/middleware_tests/test_enclosing.py | 87 ++++++++++++++++++++++++ tests/test_model.py | 53 +++++++++++++++ 4 files changed, 234 insertions(+), 8 deletions(-) diff --git a/bibtexparser/middlewares/enclosing.py b/bibtexparser/middlewares/enclosing.py index 08835f39..a1a51df5 100644 --- a/bibtexparser/middlewares/enclosing.py +++ b/bibtexparser/middlewares/enclosing.py @@ -1,3 +1,4 @@ +from typing import Optional from typing import Tuple from typing import Union @@ -78,6 +79,17 @@ class AddEnclosingMiddleware(BlockMiddleware): This middleware adds enclosing characters to a field value. It is useful when the field value is enclosed in braces or quotes. + + The enclosing to add is determined with the following precedence: + + 1. An enclosing demanded on the field or string itself + (i.e., a non-None ``field.enclosing``, e.g. set by a month middleware) + is always honored. + 2. If ``reuse_previous_enclosing`` is True, the enclosing removed by the + ``RemoveEnclosingMiddleware`` (if any) is used. + 3. If the value is an integer in a common int field + and ``enclose_integers`` is False, no enclosing is added. + 4. Otherwise, the ``default_enclosing`` is used. """ def __init__( @@ -116,19 +128,27 @@ def __init__( def metadata_key(cls) -> str: return "remove_enclosing" - def _enclose(self, value: str, metadata_enclosing: str, apply_int_rule: bool) -> str: + def _enclose( + self, + value: str, + metadata_enclosing: Optional[str], + apply_int_rule: bool, + demanded_enclosing: Optional[str] = None, + ) -> str: enclosing = self._default_enclosing - if self._reuse_previous_enclosing and metadata_enclosing is not None: + if demanded_enclosing is not None: + enclosing = demanded_enclosing + elif self._reuse_previous_enclosing and metadata_enclosing is not None: enclosing = metadata_enclosing - elif apply_int_rule and not self._enclose_integers and value.isdigit(): - return value + elif apply_int_rule and not self._enclose_integers and str(value).isdigit(): + return str(value) if enclosing == "{": return f"{{{value}}}" if enclosing == '"': return f'"{value}"' if enclosing == "no-enclosing": - return value + return str(value) raise ValueError( f"enclosing must be either '{{' or '\"' or 'no-enclosing', " f"not '{enclosing}'" ) @@ -144,7 +164,12 @@ def transform_entry(self, entry: Entry, *args, **kwargs) -> Entry: prev_encoding = ( metadata_enclosing.get(field.key, None) if metadata_enclosing is not None else None ) - field.value = self._enclose(field.value, prev_encoding, apply_int_rule=apply_int_rule) + field.value = self._enclose( + field.value, + prev_encoding, + apply_int_rule=apply_int_rule, + demanded_enclosing=field.enclosing, + ) return entry # docstr-coverage: inherited @@ -154,5 +179,6 @@ def transform_string(self, string: String, *args, **kwargs) -> String: string.value, string.parser_metadata.get(metadata_key), apply_int_rule=STRINGS_CAN_BE_UNESCAPED_INTS, + demanded_enclosing=string.enclosing, ) return string diff --git a/bibtexparser/model.py b/bibtexparser/model.py index f11a1cab..01b469b2 100644 --- a/bibtexparser/model.py +++ b/bibtexparser/model.py @@ -7,6 +7,17 @@ from typing import Tuple +_ALLOWED_ENCLOSINGS = (None, "{", '"', "no-enclosing") + + +def _validated_enclosing(enclosing: Optional[str]) -> Optional[str]: + if enclosing not in _ALLOWED_ENCLOSINGS: + raise ValueError( + "enclosing must be one of None, '{', '\"' or 'no-enclosing', " f"not {enclosing!r}" + ) + return enclosing + + class Block(abc.ABC): """An abstract superclass of all top-level building blocks of a bibtex file. @@ -86,10 +97,12 @@ def __init__( value: str, start_line: Optional[int] = None, raw: Optional[str] = None, + enclosing: Optional[str] = None, ): super().__init__(start_line, raw) self._key = key self._value = value + self._enclosing = _validated_enclosing(enclosing) @property def key(self) -> str: @@ -108,6 +121,22 @@ def value(self) -> str: @value.setter def value(self, value: str): self._value = value + self._enclosing = None + + @property + def enclosing(self) -> Optional[str]: + """The enclosing demanded when writing this string, e.g. ``'{'``. + + Allowed values are ``'{'``, ``'"'`` and ``'no-enclosing'``. + ``None`` (the default) leaves the choice to the writing middleware + (see ``AddEnclosingMiddleware``), which is the right choice for most use-cases. + + Note: Assigning a new ``value`` to the string resets this attribute to ``None``.""" + return self._enclosing + + @enclosing.setter + def enclosing(self, enclosing: Optional[str]): + self._enclosing = _validated_enclosing(enclosing) def __str__(self) -> str: return f"String (line: {self.start_line}, key: `{self.key}`): `{self.value}`" @@ -197,10 +226,17 @@ def __repr__(self) -> str: class Field: """A field of a Bibtex entry, e.g. ``author = {John Doe}``.""" - def __init__(self, key: str, value: Any, start_line: Optional[int] = None): + def __init__( + self, + key: str, + value: Any, + start_line: Optional[int] = None, + enclosing: Optional[str] = None, + ): self._start_line = start_line self._key = key self._value = value + self._enclosing = _validated_enclosing(enclosing) @property def key(self) -> str: @@ -219,6 +255,27 @@ def value(self) -> Any: @value.setter def value(self, value: Any): self._value = value + self._enclosing = None + + @property + def enclosing(self) -> Optional[str]: + """The enclosing demanded when writing this field, e.g. ``'{'``. + + Allowed values are ``'{'``, ``'"'`` and ``'no-enclosing'``. + ``None`` (the default) leaves the choice to the writing middleware + (see ``AddEnclosingMiddleware``), which is the right choice for most use-cases. + + A demand of ``'no-enclosing'`` is used for values that must be written verbatim, + such as bibtex string references (``month = jan``) + or concatenation expressions (``pages = intro # outro``), + where adding an enclosing would change the semantics of the value. + + Note: Assigning a new ``value`` to the field resets this attribute to ``None``.""" + return self._enclosing + + @enclosing.setter + def enclosing(self, enclosing: Optional[str]): + self._enclosing = _validated_enclosing(enclosing) @property def start_line(self) -> int: @@ -237,7 +294,10 @@ def __str__(self) -> str: return f"Field (line: {self.start_line}, key: `{self.key}`): `{self.value}`" def __repr__(self) -> str: - return f"Field(key=`{self.key}`, value=`{self.value}`, " f"start_line={self.start_line})" + return ( + f"Field(key=`{self.key}`, value=`{self.value}`, " + f"start_line={self.start_line}, enclosing={self._enclosing!r})" + ) class Entry(Block): diff --git a/tests/middleware_tests/test_enclosing.py b/tests/middleware_tests/test_enclosing.py index b2f96b81..bf4a9318 100644 --- a/tests/middleware_tests/test_enclosing.py +++ b/tests/middleware_tests/test_enclosing.py @@ -277,4 +277,91 @@ def test_no_addition_block_types( ) +@pytest.mark.parametrize("demanded_enclosing", ["{", '"', "no-enclosing"]) +@pytest.mark.parametrize("metadata_enclosing", ["{", '"', "no-enclosing", None]) +@pytest.mark.parametrize("default_enclosing", ["{", '"']) +@pytest.mark.parametrize("reuse_previous_enclosing", [True, False], ids=["reuse", "no_reuse"]) +def test_demanded_enclosing_takes_precedence_on_entry( + demanded_enclosing: str, + metadata_enclosing: str, + default_enclosing: str, + reuse_previous_enclosing: bool, +): + """A `field.enclosing` demand must win over all other enclosing rules. See issue #447.""" + field = Field(key="month", value="jan", start_line=6, enclosing=demanded_enclosing) + input_entry = Entry( + start_line=5, + entry_type="article", + raw="<--- does not matter for this unit test -->", + key="someKey", + fields=[field], + ) + if metadata_enclosing is not None: + input_entry.parser_metadata["removed_enclosing"] = {"month": metadata_enclosing} + + middleware = AddEnclosingMiddleware( + allow_inplace_modification=True, + default_enclosing=default_enclosing, + reuse_previous_enclosing=reuse_previous_enclosing, + enclose_integers=True, + ) + + transformed = middleware.transform(library=Library([input_entry])).entries[0] + + expected = {"{": "{jan}", '"': '"jan"', "no-enclosing": "jan"}[demanded_enclosing] + assert transformed["month"] == expected + # The demand is consumed when the new (enclosed) value is assigned + assert transformed.fields_dict["month"].enclosing is None + + +@pytest.mark.parametrize("value", [8, "8"], ids=["int", "digit-str"]) +def test_demanded_no_enclosing_on_int_value(value): + """Demanding 'no-enclosing' on an int field must yield an unenclosed string value.""" + field = Field(key="month", value=value, start_line=6, enclosing="no-enclosing") + input_entry = Entry( + start_line=5, + entry_type="article", + raw="<--- does not matter for this unit test -->", + key="someKey", + fields=[field], + ) + + middleware = AddEnclosingMiddleware( + allow_inplace_modification=True, + default_enclosing="{", + reuse_previous_enclosing=False, + enclose_integers=True, + ) + + transformed = middleware.transform(library=Library([input_entry])).entries[0] + assert transformed["month"] == "8" + + +@pytest.mark.parametrize("demanded_enclosing", ["{", '"', "no-enclosing"]) +def test_demanded_enclosing_takes_precedence_on_string(demanded_enclosing: str): + input_string = String( + start_line=5, + raw="<--- does not matter for this unit test -->", + key="someKey", + value="intro # outro", + enclosing=demanded_enclosing, + ) + + middleware = AddEnclosingMiddleware( + allow_inplace_modification=True, + default_enclosing="{", + reuse_previous_enclosing=False, + enclose_integers=True, + ) + + transformed = middleware.transform(library=Library([input_string])).strings[0] + expected = { + "{": "{intro # outro}", + '"': '"intro # outro"', + "no-enclosing": "intro # outro", + }[demanded_enclosing] + assert transformed.value == expected + assert transformed.enclosing is None + + # TODO round-trip tests (removal -> addition -> removal) diff --git a/tests/test_model.py b/tests/test_model.py index e584b33f..497fce47 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -315,6 +315,59 @@ def test_entry_str(): assert str(entry) == expected +@pytest.mark.parametrize("enclosing", ["{", '"', "no-enclosing", None]) +def test_field_enclosing_demand(enclosing): + # Default is None (writer middleware decides) + field = Field("month", "jan") + assert field.enclosing is None + + # Settable via attribute and constructor + field.enclosing = enclosing + assert field.enclosing == enclosing + assert Field("month", "jan", enclosing=enclosing).enclosing == enclosing + + # Assigning a new value resets the demanded enclosing + field.value = "feb" + assert field.enclosing is None + + +def test_field_enclosing_validation(): + field = Field("month", "jan") + with pytest.raises(ValueError): + field.enclosing = "invalid" + with pytest.raises(ValueError): + Field("month", "jan", enclosing="invalid") + + +def test_field_equality_considers_enclosing(): + assert Field("month", "jan") != Field("month", "jan", enclosing="no-enclosing") + assert Field("month", "jan", enclosing="{") == Field("month", "jan", enclosing="{") + + +@pytest.mark.parametrize("enclosing", ["{", '"', "no-enclosing", None]) +def test_string_enclosing_demand(enclosing): + # Default is None (writer middleware decides) + string = String("myKey", "myValue", 1, "raw") + assert string.enclosing is None + + # Settable via attribute and constructor + string.enclosing = enclosing + assert string.enclosing == enclosing + assert String("myKey", "myValue", 1, "raw", enclosing=enclosing).enclosing == enclosing + + # Assigning a new value resets the demanded enclosing + string.value = "myNewValue" + assert string.enclosing is None + + +def test_string_enclosing_validation(): + string = String("myKey", "myValue", 1, "raw") + with pytest.raises(ValueError): + string.enclosing = "invalid" + with pytest.raises(ValueError): + String("myKey", "myValue", 1, "raw", enclosing="invalid") + + def test_entry_fields_shorthand(): entry = Entry( entry_type="article", From 40c91cbc68986c68e361a528be6546b62725d3b2 Mon Sep 17 00:00:00 2001 From: Michael Weiss Date: Fri, 12 Jun 2026 07:39:05 +0200 Subject: [PATCH 2/5] :sparkles: Month middlewares demand `no-enclosing` for created macros/ints `MonthAbbreviationMiddleware` and `MonthIntMiddleware` now set a `no-enclosing` demand on the month field, such that the default unparse stack writes `month = jan` instead of `month = {jan}` (which would turn the string reference into a literal). Also fixes a latent bug where the middlewares' unchanged-month paths returned the `Field` object instead of its value, assigning the field to itself as value. Fixes #447 Co-Authored-By: Claude Fable 5 --- bibtexparser/middlewares/month.py | 35 ++++++++++++++-- tests/middleware_tests/test_month.py | 62 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/bibtexparser/middlewares/month.py b/bibtexparser/middlewares/month.py index 73a8f5f9..8bc24cd9 100644 --- a/bibtexparser/middlewares/month.py +++ b/bibtexparser/middlewares/month.py @@ -1,5 +1,6 @@ import abc from collections import OrderedDict +from typing import Optional from typing import Tuple from typing import Union @@ -30,6 +31,9 @@ def transform_entry(self, entry: Entry, library: "Library") -> Block: new_val, meta = self.resolve_month_field_val(month) month.value = new_val + demanded_enclosing = self._demanded_enclosing(new_val) + if demanded_enclosing is not None: + month.enclosing = demanded_enclosing entry.parser_metadata[self.metadata_key()] = meta return entry @@ -44,6 +48,10 @@ def resolve_month_field_val(self, month_field: Field) -> Tuple[Union[str, int], A tuple of the transformed value and the metadata.""" raise NotImplementedError("Abstract method") + def _demanded_enclosing(self, new_value: Union[str, int]) -> Optional[str]: + """The `Field.enclosing` to demand for the transformed month value, if any.""" + return None + _MONTH_ABBREV_TO_FULL = OrderedDict( [ @@ -93,7 +101,7 @@ def resolve_month_field_val(self, month_field: Field): if isinstance(v, int): if v < 1 or v > 12: return ( - month_field, + month_field.value, f"month-field unchanged - unknown month {v}", ) # Nothing we can do here return _MONTH_FULL[v - 1], "transformed int-month to str-month" @@ -122,13 +130,24 @@ class MonthAbbreviationMiddleware(_MonthInterpolator): will not be transformed. If you want to transform these values, you should use this middleware after the RemoveEnclosingMiddleware. - The created abbreviations are always lowercase and unenclosed.""" + The created abbreviations are always lowercase and unenclosed. + As the abbreviations are bibtex string references (macros, e.g. `jan`), + a `no-enclosing` demand is set on the month field (see `Field.enclosing`), + making sure the `AddEnclosingMiddleware` keeps them unenclosed when writing.""" # docstr-coverage: inherited @classmethod def metadata_key(cls) -> str: return "MonthAbbreviationMiddleware" + # docstr-coverage: inherited + def _demanded_enclosing(self, new_value: Union[str, int]) -> Optional[str]: + # Month macros (jan, feb, ...) must not be enclosed when written, + # as that would turn them into plain strings instead of references. + if isinstance(new_value, str) and new_value in _MONTH_ABBREV: + return "no-enclosing" + return None + # docstr-coverage: inherited def resolve_month_field_val(self, month_field: Field): v = month_field.value @@ -137,7 +156,7 @@ def resolve_month_field_val(self, month_field: Field): if isinstance(v, int): if v < 1 or v > 12: # Nothing we can do here - return month_field, f"month-field unchanged - unknown month {v}" + return month_field.value, f"month-field unchanged - unknown month {v}" return _MONTH_ABBREV[v - 1], "transformed int-month to abbreviated month" elif isinstance(v, str): v_lower = v.lower() @@ -157,13 +176,21 @@ class MonthIntMiddleware(_MonthInterpolator): will not be transformed. If you want to transform these values, you should use this middleware after the RemoveEnclosingMiddleware. - The created int-months are always integers and unenclosed.""" + The created int-months are always integers and unenclosed. + A `no-enclosing` demand is set on the month field (see `Field.enclosing`), + making sure the `AddEnclosingMiddleware` keeps them unenclosed when writing.""" # docstr-coverage: inherited @classmethod def metadata_key(cls) -> str: return "MonthIntMiddleware" + # docstr-coverage: inherited + def _demanded_enclosing(self, new_value: Union[str, int]) -> Optional[str]: + if isinstance(new_value, int): + return "no-enclosing" + return None + # docstr-coverage: inherited def resolve_month_field_val(self, month_field: Field): v = month_field.value diff --git a/tests/middleware_tests/test_month.py b/tests/middleware_tests/test_month.py index c67c6541..2b3754c7 100644 --- a/tests/middleware_tests/test_month.py +++ b/tests/middleware_tests/test_month.py @@ -1,3 +1,4 @@ +import bibtexparser from bibtexparser.middlewares.enclosing import RemoveEnclosingMiddleware from bibtexparser.middlewares.month import MonthAbbreviationMiddleware from bibtexparser.middlewares.month import MonthIntMiddleware @@ -119,3 +120,64 @@ def test_int_months(): assert new_library.entries_dict["doe2021"]["month"] == 4 assert new_library.entries_dict["jones2023"]["month"] == 8 assert new_library.entries_dict["smith2021"]["month"] == 11 + + +def test_abbreviation_months_set_no_enclosing_demand(): + library = Splitter(test_bibtex_string).split() + library = RemoveEnclosingMiddleware(allow_inplace_modification=True).transform(library) + library = MonthAbbreviationMiddleware(allow_inplace_modification=True).transform(library) + + # Month macros (e.g. `jan`) must not be enclosed when writing (see issue #447) + for key in ("smith2022", "doe2021", "jones2023", "smith2021"): + assert library.entries_dict[key].fields_dict["month"].enclosing == "no-enclosing" + + +def test_abbreviation_months_no_demand_on_enclosed_values(): + library = Splitter(test_bibtex_string).split() + library = MonthAbbreviationMiddleware(allow_inplace_modification=True).transform(library) + + # Still-enclosed (and thus untransformed) values get no demand + assert library.entries_dict["smith2022"].fields_dict["month"].enclosing is None + assert library.entries_dict["smith2021"].fields_dict["month"].enclosing is None + # Unenclosed values were transformed to macros and demand no enclosing + assert library.entries_dict["doe2021"].fields_dict["month"].enclosing == "no-enclosing" + assert library.entries_dict["jones2023"].fields_dict["month"].enclosing == "no-enclosing" + + +def test_int_months_set_no_enclosing_demand(): + library = Splitter(test_bibtex_string).split() + library = RemoveEnclosingMiddleware(allow_inplace_modification=True).transform(library) + library = MonthIntMiddleware(allow_inplace_modification=True).transform(library) + + for key in ("smith2022", "doe2021", "jones2023", "smith2021"): + assert library.entries_dict[key].fields_dict["month"].enclosing == "no-enclosing" + + +def test_long_string_months_set_no_demand(): + library = Splitter(test_bibtex_string).split() + library = RemoveEnclosingMiddleware(allow_inplace_modification=True).transform(library) + library = MonthLongStringMiddleware(allow_inplace_modification=True).transform(library) + + # Full month names are literals (not macros) and should be enclosed as usual + for key in ("smith2022", "doe2021", "jones2023", "smith2021"): + assert library.entries_dict[key].fields_dict["month"].enclosing is None + + +def test_issue_447_month_macro_not_enclosed_when_writing(): + """Months written as bibtex macros (e.g. `jan`) must remain unenclosed. + + See https://github.com/sciunto-org/python-bibtexparser/issues/447 + """ + library = bibtexparser.parse_string("@article{test447,\n month = {1},\n year = {2019}\n}") + written = bibtexparser.write_string( + library, prepend_middleware=[MonthAbbreviationMiddleware()] + ) + assert "month = jan" in written + # Other values are still enclosed with the default enclosing + assert "year = {2019}" in written + + +def test_int_month_not_enclosed_when_writing(): + library = bibtexparser.parse_string("@article{test447,\n month = {jan}\n}") + written = bibtexparser.write_string(library, prepend_middleware=[MonthIntMiddleware()]) + assert "month = 1" in written From 7e37177f411c45bf154a067f71ffd60bbcf9df5d Mon Sep 17 00:00:00 2001 From: Michael Weiss Date: Fri, 12 Jun 2026 07:40:31 +0200 Subject: [PATCH 3/5] :bug: Preserve `no-enclosing` of references on parse-write roundtrip Previously, a plain parse -> write roundtrip turned unresolved string references and concatenations into literals (e.g. `month = jan` became `month = {jan}`), silently changing their semantics. The `RemoveEnclosingMiddleware` now sets a `no-enclosing` demand on unenclosed, non-numeric values, which the `AddEnclosingMiddleware` honors. Plain integer values (e.g. `year = 2019`) remain subject to the writer's `enclose_integers` option, as before. Related to #447 Co-Authored-By: Claude Fable 5 --- bibtexparser/middlewares/enclosing.py | 13 ++++++ tests/middleware_tests/test_enclosing.py | 58 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/bibtexparser/middlewares/enclosing.py b/bibtexparser/middlewares/enclosing.py index a1a51df5..542914f8 100644 --- a/bibtexparser/middlewares/enclosing.py +++ b/bibtexparser/middlewares/enclosing.py @@ -31,6 +31,12 @@ class RemoveEnclosingMiddleware(BlockMiddleware): It is useful when the field value is enclosed in braces or quotes (which is the case for the vast majority of values). + Values that were not enclosed and are not plain integers + (i.e., unresolved bibtex string references such as `month = jan` + and concatenation expressions such as `pages = intro # outro`) + get a `no-enclosing` demand (see `Field.enclosing`), + as enclosing them when writing would change their semantics. + Note: If you want to interpolate strings, you should do so before removing any enclosing. """ @@ -62,6 +68,10 @@ def transform_entry(self, entry: Entry, library: Library) -> Entry: for field in entry.fields: stripped, enclosing = self._strip_enclosing(field.value) field.value = stripped + if enclosing == "no-enclosing" and not stripped.isdigit(): + # String references and concatenations must remain unenclosed, + # as enclosing them would change their semantics. + field.enclosing = "no-enclosing" metadata[field.key] = enclosing entry.parser_metadata[self.metadata_key()] = metadata return entry @@ -70,6 +80,9 @@ def transform_entry(self, entry: Entry, library: Library) -> Entry: def transform_string(self, string: String, library: Library) -> String: stripped, enclosing = self._strip_enclosing(string.value) string.value = stripped + if enclosing == "no-enclosing" and not stripped.isdigit(): + # See corresponding comment in `transform_entry` + string.enclosing = "no-enclosing" string.parser_metadata[self.metadata_key()] = enclosing return string diff --git a/tests/middleware_tests/test_enclosing.py b/tests/middleware_tests/test_enclosing.py index bf4a9318..7964eb3c 100644 --- a/tests/middleware_tests/test_enclosing.py +++ b/tests/middleware_tests/test_enclosing.py @@ -3,6 +3,7 @@ import pytest +import bibtexparser from bibtexparser.library import Library from bibtexparser.middlewares.enclosing import AddEnclosingMiddleware from bibtexparser.middlewares.enclosing import RemoveEnclosingMiddleware @@ -364,4 +365,61 @@ def test_demanded_enclosing_takes_precedence_on_string(demanded_enclosing: str): assert transformed.enclosing is None +def test_removal_sets_no_enclosing_demand_for_references(): + """Unenclosed non-numeric values (i.e., string references and concatenations) + must keep their `no-enclosing` when writing. See issue #447.""" + fields = [ + Field(value="jan", start_line=6, key="month"), + Field(value='jan # "~1st"', start_line=7, key="day"), + Field(value="2019", start_line=8, key="year"), + Field(value="{Some Title}", start_line=9, key="title"), + Field(value='"Some Author"', start_line=10, key="author"), + ] + input_entry = Entry( + start_line=5, + entry_type="article", + raw="<--- does not matter for this unit test -->", + key="someKey", + fields=fields, + ) + + middleware = RemoveEnclosingMiddleware(allow_inplace_modification=True) + transformed = middleware.transform(library=Library([input_entry])).entries[0] + + # References and concatenations demand to remain unenclosed + assert transformed.fields_dict["month"].enclosing == "no-enclosing" + assert transformed.fields_dict["day"].enclosing == "no-enclosing" + # Ints remain subject to the writer's `enclose_integers` option + assert transformed.fields_dict["year"].enclosing is None + # Previously enclosed values remain subject to the writer's defaults + assert transformed.fields_dict["title"].enclosing is None + assert transformed.fields_dict["author"].enclosing is None + + +def test_removal_sets_no_enclosing_demand_on_string_block(): + input_string = String( + start_line=5, + raw="<--- does not matter for this unit test -->", + key="someKey", + value="intro # outro", + ) + + middleware = RemoveEnclosingMiddleware(allow_inplace_modification=True) + transformed = middleware.transform(library=Library([input_string])).strings[0] + assert transformed.enclosing == "no-enclosing" + + +def test_string_reference_roundtrip(): + """Default parse -> write must not enclose string references and concatenations, + as this would change their semantics. See issue #447.""" + bibtex = '@article{someKey,\n\tmonth = jan,\n\tpages = intro # "--" # outro,\n\tyear = 2019\n}' + library = bibtexparser.parse_string(bibtex) + written = bibtexparser.write_string(library) + + 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 + + # TODO round-trip tests (removal -> addition -> removal) From 90e929de5f89b9655c20a1765461a3c3efc52b47 Mon Sep 17 00:00:00 2001 From: Michael Weiss Date: Fri, 12 Jun 2026 07:44:06 +0200 Subject: [PATCH 4/5] :memo: Document value enclosing demands Co-Authored-By: Claude Fable 5 --- docs/source/customize.rst | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/source/customize.rst b/docs/source/customize.rst index 6f94d6d0..eb54fbf7 100644 --- a/docs/source/customize.rst +++ b/docs/source/customize.rst @@ -211,6 +211,37 @@ The metadata attribute and its exact specification is still experimental and sub even within minor/path versions. Even when not experimental anymore, it is not intended to be used by users directly, and may be changed as needed by the corresponding middleware maintainers. +.. _value_enclosing: + +Enclosing of Values when Writing +-------------------------------- + +When writing a library, the :class:`bibtexparser.middlewares.AddEnclosingMiddleware` +(part of the default write stack) decides for each value whether to enclose it +in curly braces or quotes. By default, values are enclosed in curly braces +(integers in common int-fields can be left unenclosed, see the middleware's docstring). + +Values that must not be enclosed — as enclosing would change their semantics — +demand this via the ``enclosing`` attribute on the ``Field`` (or ``String``) instance, +which takes precedence over all other enclosing rules. This applies for example to +bibtex string references and concatenation expressions, such as month macros: + +.. code-block:: python + + library = bibtexparser.parse_string('@article{k, month = {1}, year = {2000}}') + bib_str = bibtexparser.write_string( + library, + prepend_middleware=[bibtexparser.middlewares.MonthAbbreviationMiddleware()], + ) + # The month abbreviation middleware demands `month.enclosing = "no-enclosing"`, + # hence the written entry contains `month = jan` (a string reference) + # and not `month = {jan}` (a literal). + +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. +Note that assigning a new ``value`` to a field resets its ``enclosing`` to ``None``. + .. _writing_formatting: Formatting Options for Writing From 7bdd36f08238569c17ce71faf144f42c895503ef Mon Sep 17 00:00:00 2001 From: Michael Weiss Date: Fri, 12 Jun 2026 07:45:03 +0200 Subject: [PATCH 5/5] :art: Apply pre-commit formatting Co-Authored-By: Claude Fable 5 --- bibtexparser/model.py | 1 - tests/middleware_tests/test_month.py | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/bibtexparser/model.py b/bibtexparser/model.py index 01b469b2..7dcd72d9 100644 --- a/bibtexparser/model.py +++ b/bibtexparser/model.py @@ -6,7 +6,6 @@ from typing import Set from typing import Tuple - _ALLOWED_ENCLOSINGS = (None, "{", '"', "no-enclosing") diff --git a/tests/middleware_tests/test_month.py b/tests/middleware_tests/test_month.py index 2b3754c7..4a1020a6 100644 --- a/tests/middleware_tests/test_month.py +++ b/tests/middleware_tests/test_month.py @@ -169,9 +169,7 @@ def test_issue_447_month_macro_not_enclosed_when_writing(): See https://github.com/sciunto-org/python-bibtexparser/issues/447 """ library = bibtexparser.parse_string("@article{test447,\n month = {1},\n year = {2019}\n}") - written = bibtexparser.write_string( - library, prepend_middleware=[MonthAbbreviationMiddleware()] - ) + written = bibtexparser.write_string(library, prepend_middleware=[MonthAbbreviationMiddleware()]) assert "month = jan" in written # Other values are still enclosed with the default enclosing assert "year = {2019}" in written