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
51 changes: 45 additions & 6 deletions bibtexparser/middlewares/enclosing.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from typing import Optional
from typing import Tuple
from typing import Union

Expand Down Expand Up @@ -30,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.
"""
Expand Down Expand Up @@ -61,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
Expand All @@ -69,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

Expand All @@ -78,6 +92,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__(
Expand Down Expand Up @@ -116,19 +141,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}'"
)
Expand All @@ -144,7 +177,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
Expand All @@ -154,5 +192,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
35 changes: 31 additions & 4 deletions bibtexparser/middlewares/month.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import abc
from collections import OrderedDict
from typing import Optional
from typing import Tuple
from typing import Union

Expand Down Expand Up @@ -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

Expand All @@ -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(
[
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand Down
63 changes: 61 additions & 2 deletions bibtexparser/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@
from typing import Set
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.
Expand Down Expand Up @@ -86,10 +96,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:
Expand All @@ -108,6 +120,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}`"
Expand Down Expand Up @@ -197,10 +225,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:
Expand All @@ -219,6 +254,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:
Expand All @@ -237,7 +293,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):
Expand Down
31 changes: 31 additions & 0 deletions docs/source/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading