diff --git a/bibtexparser/writer.py b/bibtexparser/writer.py index a3d7af7c..bee53b64 100644 --- a/bibtexparser/writer.py +++ b/bibtexparser/writer.py @@ -62,11 +62,30 @@ def _treat_expl_comment(block: ExplicitComment, bibtex_format: "BibtexFormat") - def _treat_failed_block(block: ParsingFailedBlock, bibtex_format: "BibtexFormat") -> List[str]: + if block.raw is None: + raise ValueError(_failed_blocks_without_raw_error([block])) lines = len(block.raw.splitlines()) parsing_failed_comment = PARSING_FAILED_COMMENT.format(n=lines) return [parsing_failed_comment, "\n", block.raw, "\n"] +def _failed_blocks_without_raw_error(blocks: List[ParsingFailedBlock]) -> str: + descriptions = "\n".join(f" - {type(b).__name__}: {b.error}" for b in blocks) + return ( + "Cannot write library: it contains failed blocks without raw bibtex " + "(typically created programmatically, not by parsing):\n" + f"{descriptions}\n" + "Inspect `library.failed_blocks` to resolve this, e.g. by removing these blocks " + "or by fixing and re-adding their `block.ignore_error_block`." + ) + + +def _raise_on_unwritable_blocks(library: Library) -> None: + unwritable = [b for b in library.blocks if isinstance(b, ParsingFailedBlock) and b.raw is None] + if unwritable: + raise ValueError(_failed_blocks_without_raw_error(unwritable)) + + def _calculate_auto_value_align(library: Library) -> int: max_key_len = 0 for entry in library.entries: @@ -82,7 +101,11 @@ def write(library: Library, bibtex_format: Optional["BibtexFormat"] = None) -> s The exposed entrypoint is `bibtexparser.write_string` (in entrypoint.py). :param library: BibTeX database to serialize. - :param bibtex_format: Customized BibTeX format to use (optional).""" + :param bibtex_format: Customized BibTeX format to use (optional). + :raises ValueError: If the library contains failed blocks without raw bibtex + (e.g. duplicate-key blocks resulting from programmatically created entries).""" + _raise_on_unwritable_blocks(library) + if bibtex_format is None: bibtex_format = BibtexFormat() diff --git a/tests/test_writer.py b/tests/test_writer.py index 4ee4391c..2eabaec5 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -143,3 +143,28 @@ def test_write_failed_block(): assert lines[2] == "except = {that-there-need-to-be}," assert lines[3] == "other = {multiple-lines}" assert lines[4] == "}" + + +def test_write_failed_block_without_raw_raises(): + """A failed block with no raw bibtex cannot be written (issue #400).""" + block = ParsingFailedBlock(error=ValueError("Some error"), raw=None, ignore_error_block=None) + library = Library(blocks=[block]) + with pytest.raises(ValueError, match="failed blocks without raw bibtex"): + writer.write(library) + + +def test_write_manually_created_duplicate_entries_raises(): + """Reproduces issue #400: duplicate keys on programmatically created entries.""" + library = Library() + library.add(_dummy_entry()) + library.add(_dummy_entry()) + with pytest.raises(ValueError, match="failed_blocks"): + writer.write(library) + + +def test_write_raises_listing_all_unwritable_blocks(): + first = ParsingFailedBlock(error=ValueError("first error"), raw=None) + second = ParsingFailedBlock(error=ValueError("second error"), raw=None) + library = Library(blocks=[first, _dummy_entry(), second]) + with pytest.raises(ValueError, match="(?s)first error.*second error"): + writer.write(library)