From 6fb7d65830740167e8ffd135a385050dcfe0182c Mon Sep 17 00:00:00 2001 From: Michael Weiss Date: Thu, 3 Sep 2026 23:04:50 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20Materialize=20iterable=20mid?= =?UTF-8?q?dleware=20arguments=20before=20inspecting=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_build_parse_stack` and `_build_unparse_stack` iterated the passed `append_middleware`/`prepend_middleware` once to compute the middleware types and then again to build the stack. With a generator or any other one-shot iterator (both are valid `Iterable[Middleware]`, as the public signatures promise) the second pass saw nothing and the middleware was silently dropped. Co-Authored-By: Claude Opus 5 --- bibtexparser/entrypoint.py | 16 ++++++-- tests/test_entrypoint.py | 84 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/bibtexparser/entrypoint.py b/bibtexparser/entrypoint.py index 6b09737..93f32bb 100644 --- a/bibtexparser/entrypoint.py +++ b/bibtexparser/entrypoint.py @@ -32,6 +32,10 @@ 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. " @@ -46,9 +50,9 @@ def _build_parse_stack( if append_middleware is None: return list(parse_stack) - parse_stack_types = [type(m) for m in 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 = set(parse_stack_types).intersection(append_stack_types) + 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 " @@ -62,6 +66,10 @@ 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. " @@ -76,9 +84,9 @@ def _build_unparse_stack( if prepend_middleware is None: return list(unparse_stack) - parse_stack_types = [type(m) for m in 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 = set(parse_stack_types).intersection(append_stack_types) + 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 " diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index 6da1448..5edce99 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -15,6 +15,8 @@ from bibtexparser.entrypoint import LARGE_LIBRARY_WARNING_THRESHOLD from bibtexparser.library import Library from bibtexparser.middlewares import MonthAbbreviationMiddleware +from bibtexparser.middlewares import RemoveEnclosingMiddleware +from bibtexparser.middlewares import SeparateCoAuthors from bibtexparser.middlewares import default_unparse_stack from bibtexparser.model import DuplicateBlockKeyBlock from bibtexparser.model import Entry @@ -529,3 +531,85 @@ def test_write_string_copying_stack_gets_no_upfront_copy(monkeypatch): write_string(library, unparse_stack=default_unparse_stack(allow_inplace_modification=False)) write_string(library) + + +COAUTHOR_BIBTEX = "@article{a, author = {Amy and Bob}, month = {January}, title = {T}}" + + +def test_parse_string_accepts_generator_append_middleware(): + """A one-shot iterator must not be silently exhausted before it is applied.""" + library = parse_string(COAUTHOR_BIBTEX, append_middleware=iter([SeparateCoAuthors()])) + assert library.entries[0]["author"] == ["Amy", "Bob"] + + +def test_parse_string_accepts_generator_parse_stack(): + stack = [RemoveEnclosingMiddleware(), SeparateCoAuthors()] + library = parse_string(COAUTHOR_BIBTEX, parse_stack=(m for m in stack)) + assert library.entries[0]["author"] == ["Amy", "Bob"] + + +def test_parse_file_accepts_generator_append_middleware(tmp_path): + path = tmp_path / "in.bib" + path.write_text(COAUTHOR_BIBTEX, encoding="UTF-8") + + library = parse_file(str(path), append_middleware=(m for m in [SeparateCoAuthors()])) + assert library.entries[0]["author"] == ["Amy", "Bob"] + + +def test_write_string_accepts_generator_prepend_middleware(): + library = parse_string(MONTH_BIBTEX) + + bib_str = write_string(library, prepend_middleware=iter([MonthAbbreviationMiddleware()])) + + assert "month = jan" in bib_str + + +def test_write_string_accepts_generator_unparse_stack(): + library = parse_string(MONTH_BIBTEX) + + bib_str = write_string( + library, unparse_stack=(m for m in default_unparse_stack(allow_inplace_modification=False)) + ) + + assert "month = {January}" in bib_str + + +def test_write_file_accepts_generator_prepend_middleware(tmp_path): + library = parse_string(MONTH_BIBTEX) + path = tmp_path / "out.bib" + + write_file(str(path), library, prepend_middleware=(m for m in [MonthAbbreviationMiddleware()])) + + assert "month = jan" in path.read_text(encoding="UTF-8") + + +def test_parse_string_with_generators_for_both_stack_and_append_raises_error(): + with pytest.raises(ValueError) as excinfo: + parse_string( + COAUTHOR_BIBTEX, + parse_stack=iter([]), + append_middleware=iter([SeparateCoAuthors()]), + ) + assert "append_middleware" in str(excinfo.value) + + +def test_write_string_with_generators_for_both_stack_and_prepend_raises_error(): + with pytest.raises(ValueError) as excinfo: + write_string( + Library([]), + unparse_stack=iter([]), + prepend_middleware=iter([MonthAbbreviationMiddleware()]), + ) + assert "prepend_middleware" in str(excinfo.value) + + +def test_parse_string_generator_append_middleware_warns_on_duplicate_type(): + """The duplicate-type warning must still trigger for a one-shot iterator.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + library = parse_string( + COAUTHOR_BIBTEX, append_middleware=iter([RemoveEnclosingMiddleware()]) + ) + + assert any("already in the default parse_stack" in str(warning.message) for warning in w) + assert library.entries[0]["title"] == "T" From 95ff0c537c51108a1370c7f4d59bfe8305218276 Mon Sep 17 00:00:00 2001 From: Michael Weiss Date: Thu, 3 Sep 2026 23:18:19 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=8A=20Name=20the=20write-path=20pa?= =?UTF-8?q?rameters=20in=20the=20duplicate-middleware=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unparse builder's warning was copied from the parse builder and still mentioned `append_middleware` / `parse_stack`, which do not exist on the write path. Co-Authored-By: Claude Fable 5.1 --- bibtexparser/entrypoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bibtexparser/entrypoint.py b/bibtexparser/entrypoint.py index 93f32bb..c997946 100644 --- a/bibtexparser/entrypoint.py +++ b/bibtexparser/entrypoint.py @@ -89,8 +89,8 @@ def _build_unparse_stack( 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})." + "Some middleware passed in prepend_middleware are " + f"already in the default unparse_stack ({stack_types_intersect})." ) return list(prepend_middleware) + list(unparse_stack)