import codecs
import logging
import warnings
from collections.abc import Iterable
from copy import deepcopy
from typing import Optional
from typing import TextIO
from .library import Library
from .middlewares.enclosing import REMOVED_ENCLOSING_KEY
from .middlewares.middleware import Middleware
from .middlewares.parsestack import default_parse_stack
from .middlewares.parsestack import default_unparse_stack
from .model import Block
from .model import String
from .splitter import Splitter
from .writer import BibtexFormat
from .writer import write
logger = logging.getLogger(__name__)
#: Marks a seeded copy of a pre-existing `@string`, dropped before merging back.
_PREEXISTING_STRING_KEY = "bibtexparser_preexisting_string"
#: Number of blocks from which on `write_string`/`write_file` warn if the unparse
#: stack deep-copies blocks. Copying costs roughly 30-60 µs per entry, i.e. it
#: starts to dominate the write time at this size.
LARGE_LIBRARY_WARNING_THRESHOLD = 10_000
def _build_parse_stack(
parse_stack: Iterable[Middleware] | None,
append_middleware: Iterable[Middleware] | None,
) -> list[Middleware]:
# Materialize upfront: the arguments may be one-shot iterators.
parse_stack = None if parse_stack is None else list(parse_stack)
append_middleware = None if append_middleware is None else list(append_middleware)
if parse_stack is not None and append_middleware is not None:
raise ValueError(
"Provided both parse_stack and append_middleware. "
"Only one should be provided. "
"(append_middleware should only be used with the default parse_stack, "
"i.e., when the passed parse_stack is None.)"
)
if parse_stack is None:
parse_stack = default_parse_stack(allow_inplace_modification=True)
if append_middleware is None:
return list(parse_stack)
parse_stack_types = {type(m) for m in parse_stack}
append_stack_types = {type(m) for m in append_middleware}
stack_types_intersect = parse_stack_types.intersection(append_stack_types)
if len(stack_types_intersect) > 0:
warnings.warn(
"Some middleware passed in append_middleware are "
f"already in the default parse_stack ({stack_types_intersect})."
)
return list(parse_stack) + list(append_middleware)
def _build_unparse_stack(
unparse_stack: Iterable[Middleware] | None,
prepend_middleware: Iterable[Middleware] | None,
) -> list[Middleware]:
# Materialize upfront: the arguments may be one-shot iterators.
unparse_stack = None if unparse_stack is None else list(unparse_stack)
prepend_middleware = None if prepend_middleware is None else list(prepend_middleware)
if unparse_stack is not None and prepend_middleware is not None:
raise ValueError(
"Provided both unparse_stack and prepend_middleware. "
"Only one should be provided. "
"(prepend_middleware should only be used with the default unparse_stack, "
"i.e., when the passed unparse_stack is None.)"
)
if unparse_stack is None:
unparse_stack = default_unparse_stack(allow_inplace_modification=False)
if prepend_middleware is None:
return list(unparse_stack)
parse_stack_types = {type(m) for m in unparse_stack}
append_stack_types = {type(m) for m in prepend_middleware}
stack_types_intersect = parse_stack_types.intersection(append_stack_types)
if len(stack_types_intersect) > 0:
warnings.warn(
"Some middleware passed in prepend_middleware are "
f"already in the default unparse_stack ({stack_types_intersect})."
)
return list(prepend_middleware) + list(unparse_stack)
def _warn_if_large_library_is_copied(library: Library, unparse_stack: list[Middleware]) -> None:
"""Warn if writing ``library`` will deep-copy its blocks and that is likely slow.
Middlewares with ``allow_inplace_modification=False`` (the default unparse stack
is built that way) deep-copy every block they transform, which dominates the
write time of large libraries.
"""
n_blocks = len(library.blocks)
if n_blocks < LARGE_LIBRARY_WARNING_THRESHOLD:
return
if all(middleware.allow_inplace_modification for middleware in unparse_stack):
return
logger.warning(
f"Writing a library with {n_blocks} blocks: the unparse stack deep-copies blocks "
"(it contains middlewares with allow_inplace_modification=False), "
"which is slow for large libraries. "
"If you do not need the library after writing, pass an unparse stack whose "
"middlewares all allow in-place modification, e.g. "
"`unparse_stack=bibtexparser.middlewares.default_unparse_stack("
"allow_inplace_modification=True)`."
)
def _handle_deprecated_write_params(
unparse_stack: Iterable[Middleware] | None,
prepend_middleware: Iterable[Middleware] | None,
kwargs: dict,
function_name: str,
) -> tuple[Iterable[Middleware] | None, Iterable[Middleware] | None]:
"""Handle deprecated parameter names for write functions.
:param unparse_stack: Current unparse_stack value
:param prepend_middleware: Current prepend_middleware value
:param kwargs: Dictionary of keyword arguments to check for deprecated params
:param function_name: Name of the calling function (for error messages)
:return: Tuple of (unparse_stack, prepend_middleware) with deprecated values migrated
"""
if "parse_stack" in kwargs:
warnings.warn(
"Parameter 'parse_stack' is deprecated. Use 'unparse_stack' instead.",
DeprecationWarning,
stacklevel=3,
)
if unparse_stack is not None:
raise ValueError(
"Cannot provide both 'parse_stack' (deprecated) and 'unparse_stack'. "
"Use 'unparse_stack' instead."
)
unparse_stack = kwargs.pop("parse_stack")
if "append_middleware" in kwargs:
warnings.warn(
"Parameter 'append_middleware' is deprecated. Use 'prepend_middleware' instead.",
DeprecationWarning,
stacklevel=3,
)
if prepend_middleware is not None:
raise ValueError(
"Cannot provide both 'append_middleware' (deprecated) and 'prepend_middleware'. "
"Use 'prepend_middleware' instead."
)
prepend_middleware = kwargs.pop("append_middleware")
if kwargs:
raise TypeError(f"{function_name}() got unexpected keyword arguments: {', '.join(kwargs)}")
return unparse_stack, prepend_middleware
[docs]
def parse_string(
bibtex_str: str,
parse_stack: Iterable[Middleware] | None = None,
append_middleware: Iterable[Middleware] | None = None,
library: Library | None = None,
) -> Library:
"""Parse a BibTeX string.
:param bibtex_str: BibTeX string to parse
:param parse_stack:
List of middleware to apply to the database after splitting.
If ``None`` (default), a default stack will be used providing simple standard functionality.
:param append_middleware:
List of middleware to append to the default stack
(ignored if a not-``None`` parse_stack is passed).
:param library:
Library to add the newly parsed blocks to.
If ``None`` (default), a new library is created and returned.
If a library is passed, it is returned (mutated) and:
- the parse stack is applied **only** to the newly parsed blocks;
blocks already contained in the passed library are left untouched
(they were already transformed when they were parsed);
- ``@string`` blocks already contained in the passed library are visible
to the parse stack, i.e. string references in ``bibtex_str`` resolve
against them (unless ``bibtex_str`` redefines the same key);
- keys defined both in the passed library and in ``bibtex_str``
do not raise, but yield ``DuplicateBlockKeyBlock`` instances
(see ``library.failed_blocks``), just like duplicates within a single string.
:return: Library: Parsed BibTeX database
"""
splitter = Splitter(bibstr=bibtex_str)
parsed = splitter.split()
_seed_preexisting_strings(parsed, library)
middleware: Middleware
for middleware in _build_parse_stack(parse_stack, append_middleware):
parsed = middleware.transform(library=parsed)
if library is None:
return parsed
new_blocks = [b for b in parsed.blocks if not _is_seeded_string(b)]
library.add(new_blocks, fail_on_duplicate_key=False)
return library
def _is_seeded_string(block: Block) -> bool:
"""True for blocks seeded by `_seed_preexisting_strings` (and their transformations)."""
return bool(block.get_parser_metadata(_PREEXISTING_STRING_KEY))
def _restore_enclosing(string: String) -> None:
"""Make sure the value of an already-parsed string is enclosed again.
The parse stack expects freshly split (i.e. still enclosed) values.
Feeding it an already-stripped value would make that value be treated
as an unenclosed literal (a string reference), which does not round-trip
to valid bibtex.
"""
enclosing = string.parser_metadata.pop(REMOVED_ENCLOSING_KEY, None)
if string.enclosing == "no-enclosing" or enclosing == "no-enclosing":
return
value = string.value
if not isinstance(value, str):
return
if enclosing is None and (
(value.startswith("{") and value.endswith("}"))
or (value.startswith('"') and value.endswith('"'))
):
return
string.value = f'"{value}"' if enclosing == '"' else f"{{{value}}}"
def _seed_preexisting_strings(parsed: Library, library: Library | None) -> list[String]:
"""Make the ``@string`` blocks of an existing library visible to the parse stack.
Copies (never the originals, which must not be transformed again) of the
strings of ``library`` are added to ``parsed``, unless the newly parsed
content redefines the same key. The copies are tagged so that they can be
dropped again before merging the parsed blocks back into ``library``.
:param parsed: The freshly split library, modified in place.
:param library: The pre-existing library, or ``None``.
:return: The seeded (tagged) string copies.
"""
if library is None:
return []
# Bibtex string keys are case-insensitive, hence compare in lower case.
redefined = {key.lower() for key in parsed.strings_dict}
seeds = []
for key, string in library.strings_dict.items():
if key.lower() in redefined:
continue
seed = deepcopy(string)
seed.set_parser_metadata(_PREEXISTING_STRING_KEY, True)
_restore_enclosing(seed)
seeds.append(seed)
if seeds:
parsed.add(seeds, fail_on_duplicate_key=False)
return seeds
[docs]
def parse_file(
path: str,
parse_stack: Iterable[Middleware] | None = None,
append_middleware: Iterable[Middleware] | None = None,
encoding: str = "UTF-8",
) -> Library:
"""Parse a BibTeX file
:param path: Path to BibTeX file
:param parse_stack:
List of middleware to apply to the database after splitting.
If ``None`` (default), a default stack will be used providing simple standard functionality.
:param append_middleware:
List of middleware to append to the default stack
(ignored if a not-``None`` parse_stack is passed).
:param encoding: Encoding of the .bib file. Default encoding is ``"UTF-8"``.
:return: Library: Parsed BibTeX library
:raises LookupError: If the specified encoding is not recognized.
"""
try:
codecs.lookup(encoding)
except LookupError:
raise LookupError(f"Unknown encoding: {encoding!r}")
with open(path, encoding=encoding) as f:
bibtex_str = f.read()
return parse_string(
bibtex_str, parse_stack=parse_stack, append_middleware=append_middleware
)
[docs]
def write_file(
file: str | TextIO,
library: Library,
unparse_stack: Iterable[Middleware] | None = None,
prepend_middleware: Iterable[Middleware] | None = None,
bibtex_format: BibtexFormat | None = None,
encoding: str = "UTF-8",
**kwargs,
) -> 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.
If None, a default stack will be used.
:param prepend_middleware: List of middleware to prepend to the default stack.
Only applicable if `unparse_stack` is None.
:param bibtex_format: Customized BibTeX format to use (optional).
:param encoding: Encoding of the .bib file. Default encoding is ``"UTF-8"``.
Writing a library with at least ``LARGE_LIBRARY_WARNING_THRESHOLD`` blocks logs a warning
if the unparse stack deep-copies blocks (middlewares with ``allow_inplace_modification=False``),
as that is slow; pass an all-in-place stack to avoid it.
.. deprecated:: (next version)
Parameters 'parse_stack' and 'append_middleware' are deprecated, will be deleted soon.
Use 'unparse_stack' and 'prepend_middleware' instead.
"""
unparse_stack, prepend_middleware = _handle_deprecated_write_params(
unparse_stack, prepend_middleware, kwargs, "write_file"
)
bibtex_str = write_string(
library=library,
unparse_stack=unparse_stack,
prepend_middleware=prepend_middleware,
bibtex_format=bibtex_format,
)
if isinstance(file, str):
with open(file, "w", encoding=encoding) as f:
f.write(bibtex_str)
else:
file.write(bibtex_str)
[docs]
def write_string(
library: Library,
unparse_stack: Iterable[Middleware] | None = None,
prepend_middleware: Iterable[Middleware] | None = None,
bibtex_format: Optional["BibtexFormat"] = None,
**kwargs,
) -> 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.
:param prepend_middleware: List of middleware to prepend to the default stack.
Only applicable if `unparse_stack` is None.
:param bibtex_format: Customized BibTeX format to use (optional).
Writing a library with at least ``LARGE_LIBRARY_WARNING_THRESHOLD`` blocks logs a warning
if the unparse stack deep-copies blocks (middlewares with ``allow_inplace_modification=False``),
as that is slow; pass an all-in-place stack to avoid it.
.. deprecated:: (next version)
Parameters 'parse_stack' and 'append_middleware' are deprecated.
Use 'unparse_stack' and 'prepend_middleware' instead.
"""
unparse_stack, prepend_middleware = _handle_deprecated_write_params(
unparse_stack, prepend_middleware, kwargs, "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:
library = middleware.transform(library=library)
return write(library, bibtex_format=bibtex_format)