Source code for bibtexparser.middlewares.enclosing

import re

from bibtexparser.library import Library
from bibtexparser.model import Entry
from bibtexparser.model import Field
from bibtexparser.model import String

from .middleware import BlockMiddleware

REMOVED_ENCLOSING_KEY = "removed_enclosing"

# Delimiters relevant when scanning a value, using the splitter's escaping
# convention: a delimiter is escaped iff it is directly preceded by a backslash.
_BRACES = re.compile(r"(?<!\\)[{}]")
_BRACES_AND_QUOTE = re.compile(r"(?<!\\)[{}\"]")
_UNENCLOSED_MARKS = re.compile(r"(?<!\\)[{}\",=\n]")


def _is_writable_unenclosed(value: str) -> bool:
    """Whether writing the value verbatim yields the same value when re-parsed.

    Values that are written without enclosing (e.g. string references and
    concatenations) must not contain anything the splitter would read as the
    end of the value: an unbalanced brace or, outside braces and quotes,
    a comma, an equals sign or a newline.
    """
    depth = 0
    in_quotes = False
    for match in _UNENCLOSED_MARKS.finditer(value):
        char = match.group()
        if char == "{":
            depth += 1
        elif char == "}":
            depth -= 1
            if depth < 0:
                return False
        elif depth == 0:
            if char == '"':
                in_quotes = not in_quotes
            elif not in_quotes:
                return False
    return depth == 0 and not in_quotes


STRINGS_CAN_BE_UNESCAPED_INTS = False
ENTRY_POTENTIALLY_INT_FIELDS = [
    "year",
    "month",
    "volume",
    "number",
    "pages",
    "edition",
    "chapter",
    "issue",
]


[docs] class RemoveEnclosingMiddleware(BlockMiddleware): """Remove enclosing characters from values such as field and strings. This middleware removes enclosing characters from a field value. It is useful when the field value is enclosed in braces or quotes (which is the case for the vast majority of values). Only a delimiter pair enclosing the *whole* value is removed: `pages = {intro} # {outro}` merely starts and ends in braces, but these do not belong to the same pair and are thus kept. 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` or `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. """ def __init__(self, allow_inplace_modification: bool = True): super().__init__( allow_inplace_modification=allow_inplace_modification, allow_parallel_execution=True, ) # docstr-coverage: inherited
[docs] @classmethod def metadata_key(cls) -> str: return REMOVED_ENCLOSING_KEY
@staticmethod def _is_enclosed_in_braces(value: str) -> bool: """Whether the brace opened at the first char is the one closed at the last char. This is False for values that merely start and end in braces, such as the concatenation expression `{intro} # {outro}`, whose outer braces do not enclose the whole value. Escaped braces are not counted. """ inner = value[1:-1] if "{" not in inner and "}" not in inner: # Fast path for the common case of a plainly braced value. return not inner.endswith("\\") depth = 0 last_index = len(value) - 1 for match in _BRACES.finditer(value): if match.group() == "{": depth += 1 continue depth -= 1 if depth < 0: return False if depth == 0 and match.start() != last_index: return False return depth == 0 @staticmethod def _is_enclosed_in_quotes(value: str) -> bool: """Whether the quote at the first char is the one closed at the last char. This is False for values that merely start and end in quotes, such as the concatenation expression `"intro" # "outro"`. Escaped quotes and quotes inside braces are not counted. """ last_index = len(value) - 1 if value.find('"', 1, last_index) < 0: # Fast path for the common case of a value without inner quotes. return True depth = 0 for match in _BRACES_AND_QUOTE.finditer(value): index = match.start() if index == 0 or index == last_index: continue char = match.group() if char == "{": depth += 1 elif char == "}": depth = max(depth - 1, 0) elif depth == 0: return False return True @classmethod def _strip_enclosing(cls, value: str) -> tuple[str, str | None]: value = value.strip() # A single `{` or `"` starts and ends with the same char, but is no enclosing. if len(value) >= 2: if value.startswith("{") and value.endswith("}") and cls._is_enclosed_in_braces(value): return value[1:-1], "{" if value.startswith('"') and value.endswith('"') and cls._is_enclosed_in_quotes(value): return value[1:-1], '"' return value, "no-enclosing" # docstr-coverage: inherited
[docs] def transform_entry(self, entry: Entry, library: Library) -> Entry: field: Field metadata = dict() 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
# docstr-coverage: inherited
[docs] 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
[docs] class AddEnclosingMiddleware(BlockMiddleware): """Add enclosing characters to values such as field and strings. 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. A ``no-enclosing`` resulting from 1. or 2. is overruled by the ``default_enclosing`` if the value cannot be written verbatim, i.e. if writing it as-is would not parse back to the same value. This happens when a middleware changed the value after the demand was set. """ def __init__( self, reuse_previous_enclosing: bool, enclose_integers: bool, default_enclosing: str, allow_inplace_modification: bool = True, ): """ :param reuse_previous_enclosing: Whether to reuse the previous enclosing character, i.e., the enclosing removed by the RemoveEnclosingMiddleware. (this has precedence over the default_enclosing) :param enclose_integers: Whether to enclose integers (only of no previous enclosing was applied) :param default_enclosing: The default enclosing character to use ('{', '"', or 'no-enclosing') (only of no previous enclosing was applied, and - for ints - enclose_integers is False) :param allow_inplace_modification: Whether to allow inplace modification (see BlockMiddleware docs). """ super().__init__( allow_inplace_modification=allow_inplace_modification, allow_parallel_execution=True, ) if default_enclosing not in ("{", '"'): raise ValueError( "default_enclosing must be either '{' or '\"', " f"not '{default_enclosing}'" ) self._default_enclosing = default_enclosing self._reuse_previous_enclosing = reuse_previous_enclosing self._enclose_integers = enclose_integers # docstr-coverage: inherited
[docs] @classmethod def metadata_key(cls) -> str: return "add_enclosing"
def _enclose( self, value: str, metadata_enclosing: str | None, apply_int_rule: bool, demanded_enclosing: str | None = None, ) -> str: enclosing = self._default_enclosing 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 str(value).isdigit(): return str(value) if enclosing == "no-enclosing" and not _is_writable_unenclosed(str(value)): # The value cannot be written verbatim: writing it as-is would produce # bibtex that does not parse back to this value. This happens when a # middleware changed the value after the demand was set. enclosing = self._default_enclosing if enclosing == "{": return f"{{{value}}}" if enclosing == '"': return f'"{value}"' if enclosing == "no-enclosing": return str(value) raise ValueError( f"enclosing must be either '{{' or '\"' or 'no-enclosing', " f"not '{enclosing}'" ) # docstr-coverage: inherited
[docs] def transform_entry(self, entry: Entry, library: Library) -> Entry: field: Field metadata_enclosing = entry.parser_metadata.pop( RemoveEnclosingMiddleware.metadata_key(), None ) for field in entry.fields: apply_int_rule = field.key in ENTRY_POTENTIALLY_INT_FIELDS 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, demanded_enclosing=field.enclosing, ) return entry
# docstr-coverage: inherited
[docs] def transform_string(self, string: String, library: Library) -> String: metadata_key = RemoveEnclosingMiddleware.metadata_key() string.value = self._enclose( string.value, string.parser_metadata.get(metadata_key), apply_int_rule=STRINGS_CAN_BE_UNESCAPED_INTS, demanded_enclosing=string.enclosing, ) return string