Files
wehub-resource-sync c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Waiting to run
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Python Unittest Coverage / test (macos-15, 3.11) (push) Waiting to run
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Waiting to run
Python Unittest Coverage / test (windows-latest, 3.11) (push) Waiting to run
Web UI / check (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:39:27 +08:00

1195 lines
40 KiB
Python

# -*- coding: utf-8 -*-
"""Unit tests for the file parsers in :mod:`agentscope.rag._parser`.
PDF / PPTX fixtures are produced in-memory via :mod:`reportlab` and
:mod:`python-pptx` so the tests have no on-disk dependencies and can
run anywhere ``agentscope[rag]`` is installed.
"""
import base64
import io
import os
from unittest.async_case import IsolatedAsyncioTestCase
from utils import AnyString
from agentscope.rag import (
ImageParser,
PDFParser,
PPTParser,
TextParser,
WordParser,
ExcelParser,
)
# Smallest valid PNG ever — a single transparent pixel. Used wherever
# a test needs "some image bytes" without depending on Pillow.
_PNG_PIXEL: bytes = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUA"
"AarVyFEAAAAASUVORK5CYII=",
)
_PNG_PIXEL_B64: str = base64.b64encode(_PNG_PIXEL).decode("utf-8")
def _make_pdf(pages: list[str]) -> bytes:
"""Render the supplied page strings to a PDF in memory."""
from reportlab.pdfgen import canvas
buffer = io.BytesIO()
pdf = canvas.Canvas(buffer)
for page in pages:
pdf.drawString(72, 720, page)
pdf.showPage()
pdf.save()
return buffer.getvalue()
def _make_pptx_simple(slides: list[str]) -> bytes:
"""Build a PPTX in memory with one text shape per slide."""
from pptx import Presentation
prs = Presentation()
blank_layout = prs.slide_layouts[5] # title-only layout
for text in slides:
slide = prs.slides.add_slide(blank_layout)
slide.shapes.title.text = text
buffer = io.BytesIO()
prs.save(buffer)
return buffer.getvalue()
def _make_pptx_rich() -> bytes:
"""Build a richer PPTX with text, a table, and an embedded image.
Slide 1: a single title text shape.
Slide 2: a title text shape, a 2x2 table, and a trailing text shape.
Slide 3: an embedded PNG image only (blank title).
"""
from pptx import Presentation
from pptx.util import Inches
prs = Presentation()
blank_layout = prs.slide_layouts[5]
slide1 = prs.slides.add_slide(blank_layout)
slide1.shapes.title.text = "Hello"
slide2 = prs.slides.add_slide(blank_layout)
slide2.shapes.title.text = "Header"
table = slide2.shapes.add_table(
rows=2,
cols=2,
left=Inches(1),
top=Inches(2),
width=Inches(4),
height=Inches(1),
).table
table.cell(0, 0).text = "A"
table.cell(0, 1).text = "B"
table.cell(1, 0).text = "1"
table.cell(1, 1).text = "2"
trailing = slide2.shapes.add_textbox(
Inches(1),
Inches(4),
Inches(4),
Inches(1),
)
trailing.text_frame.text = "Footer"
slide3 = prs.slides.add_slide(blank_layout)
slide3.shapes.title.text = "" # blank title
slide3.shapes.add_picture(
io.BytesIO(_PNG_PIXEL),
Inches(1),
Inches(1),
Inches(1),
Inches(1),
)
buffer = io.BytesIO()
prs.save(buffer)
return buffer.getvalue()
def _make_docx_simple(paragraphs: list[str]) -> bytes:
"""Build a DOCX in memory with plain text paragraphs."""
from docx import Document as DocxDocument
doc = DocxDocument()
for text in paragraphs:
doc.add_paragraph(text)
buffer = io.BytesIO()
doc.save(buffer)
return buffer.getvalue()
def _make_docx_with_table() -> bytes:
"""Build a DOCX with a paragraph, a 2x2 table, and a trailing
paragraph."""
from docx import Document as DocxDocument
doc = DocxDocument()
doc.add_paragraph("Before table")
table = doc.add_table(rows=2, cols=2)
table.cell(0, 0).text = "A"
table.cell(0, 1).text = "B"
table.cell(1, 0).text = "1"
table.cell(1, 1).text = "2"
doc.add_paragraph("After table")
buffer = io.BytesIO()
doc.save(buffer)
return buffer.getvalue()
def _make_docx_with_image() -> bytes:
"""Build a DOCX with a paragraph and an embedded PNG image."""
from docx import Document as DocxDocument
from docx.shared import Inches
doc = DocxDocument()
doc.add_paragraph("Text before image")
doc.add_picture(io.BytesIO(_PNG_PIXEL), width=Inches(1))
doc.add_paragraph("Text after image")
buffer = io.BytesIO()
doc.save(buffer)
return buffer.getvalue()
def _make_xlsx_simple(
sheets: dict[str, list[list[str]]],
) -> bytes:
"""Build an XLSX in memory from a dict of sheet_name → rows.
Each row is a list of cell strings; the first row becomes the
header in pandas.
"""
import pandas as pd
buffer = io.BytesIO()
with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
for sheet_name, rows in sheets.items():
if rows:
df = pd.DataFrame(rows[1:], columns=rows[0])
else:
df = pd.DataFrame()
df.to_excel(writer, sheet_name=sheet_name, index=False)
return buffer.getvalue()
class TextParserTest(IsolatedAsyncioTestCase):
"""Behavioural coverage for :class:`TextParser`."""
async def test_decode_bytes(self) -> None:
"""Bytes are decoded with the configured encoding."""
parser = TextParser()
sections = await parser.parse(b"hello", "x.txt")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "hello",
"id": AnyString(),
},
"source": "x.txt",
"metadata": {},
},
],
)
async def test_pre_decoded_string_round_trips(self) -> None:
"""``str`` inputs skip the decode step when no such file exists."""
parser = TextParser()
sections = await parser.parse("preset", "x.md")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "preset",
"id": AnyString(),
},
"source": "x.md",
"metadata": {},
},
],
)
async def test_string_input_treated_as_path(self) -> None:
"""A ``str`` that names an existing file is read as a path."""
import tempfile
with tempfile.NamedTemporaryFile(
mode="w",
suffix=".txt",
delete=False,
) as f:
f.write("from disk")
path = f.name
try:
parser = TextParser()
sections = await parser.parse(path, "x.txt")
self.assertEqual(len(sections), 1)
self.assertEqual(sections[0].content.text, "from disk")
finally:
os.unlink(path)
async def test_bad_encoding_raises_value_error(self) -> None:
"""An undecodable byte sequence surfaces a clear error."""
parser = TextParser(encoding="ascii")
with self.assertRaises(ValueError):
await parser.parse(b"\xff\xfe", "bad.txt")
class PDFParserTest(IsolatedAsyncioTestCase):
"""Behavioural coverage for :class:`PDFParser`."""
async def test_one_section_per_page(self) -> None:
"""Each page in the PDF round-trips to its own Section."""
pdf_bytes = _make_pdf(["First page text", "Second page text"])
parser = PDFParser()
sections = await parser.parse(pdf_bytes, "demo.pdf")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "First page text\n",
"id": AnyString(),
},
"source": "demo.pdf",
"metadata": {"page": 1},
},
{
"content": {
"type": "text",
"text": "Second page text\n",
"id": AnyString(),
},
"source": "demo.pdf",
"metadata": {"page": 2},
},
],
)
async def test_string_input_treated_as_path(self) -> None:
"""``str`` is interpreted as a filesystem path to the PDF."""
import tempfile
pdf_bytes = _make_pdf(["Hello"])
with tempfile.NamedTemporaryFile(
suffix=".pdf",
delete=False,
) as f:
f.write(pdf_bytes)
path = f.name
try:
parser = PDFParser()
sections = await parser.parse(path, "demo.pdf")
self.assertEqual(len(sections), 1)
finally:
os.unlink(path)
async def test_missing_path_raises_file_not_found(self) -> None:
"""A ``str`` pointing at a missing path raises FileNotFoundError."""
parser = PDFParser()
with self.assertRaises(FileNotFoundError):
await parser.parse("/no/such/file.pdf", "x.pdf")
async def test_invalid_bytes_raise_value_error(self) -> None:
"""Garbage bytes surface as :class:`ValueError`."""
parser = PDFParser()
with self.assertRaises(ValueError):
await parser.parse(b"not a pdf", "broken.pdf")
async def test_supported_extensions(self) -> None:
"""``.pdf`` is the only extension exposed to the file picker."""
self.assertEqual(PDFParser.supported_extensions(), [".pdf"])
class ImageParserTest(IsolatedAsyncioTestCase):
"""Behavioural coverage for :class:`ImageParser`."""
async def test_wraps_bytes_in_data_block(self) -> None:
"""A single Section is emitted with the image as a DataBlock."""
parser = ImageParser()
sections = await parser.parse(_PNG_PIXEL, "pixel.png")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "data",
"id": AnyString(),
"source": {
"type": "base64",
"data": _PNG_PIXEL_B64,
"media_type": "image/png",
},
"name": "pixel.png",
},
"source": "pixel.png",
"metadata": {"media_type": "image/png"},
},
],
)
async def test_media_type_sniffed_from_jpeg(self) -> None:
"""JPEG magic bytes yield ``image/jpeg``."""
jpeg_bytes = b"\xff\xd8\xff\xe0rest-of-jpeg"
parser = ImageParser()
sections = await parser.parse(jpeg_bytes, "x.jpg")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "data",
"id": AnyString(),
"source": {
"type": "base64",
"data": base64.b64encode(jpeg_bytes).decode(
"utf-8",
),
"media_type": "image/jpeg",
},
"name": "x.jpg",
},
"source": "x.jpg",
"metadata": {"media_type": "image/jpeg"},
},
],
)
async def test_string_input_treated_as_path(self) -> None:
"""``str`` is interpreted as a filesystem path to the image."""
import tempfile
with tempfile.NamedTemporaryFile(
suffix=".png",
delete=False,
) as f:
f.write(_PNG_PIXEL)
path = f.name
try:
parser = ImageParser()
sections = await parser.parse(path, "pixel.png")
self.assertEqual(len(sections), 1)
finally:
os.unlink(path)
async def test_missing_path_raises_file_not_found(self) -> None:
"""A ``str`` pointing at a missing path raises FileNotFoundError."""
parser = ImageParser()
with self.assertRaises(FileNotFoundError):
await parser.parse("/no/such/file.png", "x.png")
class PPTParserTest(IsolatedAsyncioTestCase):
"""Behavioural coverage for :class:`PPTParser`."""
async def test_simple_deck_text_only(self) -> None:
"""A simple text-only deck round-trips through wrapping tags."""
pptx_bytes = _make_pptx_simple(["Alpha", "Beta"])
parser = PPTParser(include_image=False)
sections = await parser.parse(pptx_bytes, "demo.pptx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "<slide index=1>\nAlpha\n</slide>",
"id": AnyString(),
},
"source": "demo.pptx",
"metadata": {"slide": 1},
},
{
"content": {
"type": "text",
"text": "<slide index=2>\nBeta\n</slide>",
"id": AnyString(),
},
"source": "demo.pptx",
"metadata": {"slide": 2},
},
],
)
async def test_without_slide_tags(self) -> None:
"""Disabling prefix/suffix removes the slide tags entirely."""
pptx_bytes = _make_pptx_simple(["Alpha"])
parser = PPTParser(
include_image=False,
slide_prefix=None,
slide_suffix=None,
)
sections = await parser.parse(pptx_bytes, "demo.pptx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "Alpha",
"id": AnyString(),
},
"source": "demo.pptx",
"metadata": {"slide": 1},
},
],
)
async def test_table_merges_with_surrounding_text_by_default(
self,
) -> None:
"""``separate_table=False`` merges the table into the running
text section."""
pptx_bytes = _make_pptx_rich()
parser = PPTParser(include_image=False, separate_table=False)
sections = await parser.parse(pptx_bytes, "rich.pptx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "<slide index=1>\nHello\n</slide>",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 1},
},
{
"content": {
"type": "text",
"text": (
"<slide index=2>\n"
"Header\n"
"| A | B |\n"
"| --- | --- |\n"
"| 1 | 2 |\n\n"
"Footer\n"
"</slide>"
),
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 2},
},
{
"content": {
"type": "text",
"text": "<slide index=3>\n</slide>",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 3},
},
],
)
async def test_table_separated_when_separate_table_true(self) -> None:
"""``separate_table=True`` flushes the running text around the
table."""
pptx_bytes = _make_pptx_rich()
parser = PPTParser(include_image=False, separate_table=True)
sections = await parser.parse(pptx_bytes, "rich.pptx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "<slide index=1>\nHello\n</slide>",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 1},
},
{
"content": {
"type": "text",
"text": "<slide index=2>\nHeader",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 2},
},
{
"content": {
"type": "text",
"text": (
"| A | B |\n" "| --- | --- |\n" "| 1 | 2 |\n"
),
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 2},
},
{
"content": {
"type": "text",
"text": "Footer\n</slide>",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 2},
},
{
"content": {
"type": "text",
"text": "<slide index=3>\n</slide>",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 3},
},
],
)
async def test_image_emits_data_block(self) -> None:
"""An embedded picture becomes its own DataBlock section."""
pptx_bytes = _make_pptx_rich()
parser = PPTParser(include_image=True, separate_table=True)
sections = await parser.parse(pptx_bytes, "rich.pptx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "<slide index=1>\nHello\n</slide>",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 1},
},
{
"content": {
"type": "text",
"text": "<slide index=2>\nHeader",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 2},
},
{
"content": {
"type": "text",
"text": (
"| A | B |\n" "| --- | --- |\n" "| 1 | 2 |\n"
),
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 2},
},
{
"content": {
"type": "text",
"text": "Footer\n</slide>",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 2},
},
{
"content": {
"type": "text",
"text": "<slide index=3>",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 3},
},
{
"content": {
"type": "data",
"id": AnyString(),
"source": {
"type": "base64",
"data": _PNG_PIXEL_B64,
"media_type": "image/png",
},
"name": "rich.pptx",
},
"source": "rich.pptx",
"metadata": {
"slide": 3,
"media_type": "image/png",
},
},
{
"content": {
"type": "text",
"text": "</slide>",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 3},
},
],
)
async def test_table_json_format(self) -> None:
"""``table_format="json"`` emits the JSON marker payload."""
pptx_bytes = _make_pptx_rich()
parser = PPTParser(
include_image=False,
separate_table=True,
table_format="json",
)
sections = await parser.parse(pptx_bytes, "rich.pptx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "<slide index=1>\nHello\n</slide>",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 1},
},
{
"content": {
"type": "text",
"text": "<slide index=2>\nHeader",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 2},
},
{
"content": {
"type": "text",
"text": (
"<system-info>A table loaded as a JSON "
"array:</system-info>\n"
'[["A", "B"], ["1", "2"]]'
),
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 2},
},
{
"content": {
"type": "text",
"text": "Footer\n</slide>",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 2},
},
{
"content": {
"type": "text",
"text": "<slide index=3>\n</slide>",
"id": AnyString(),
},
"source": "rich.pptx",
"metadata": {"slide": 3},
},
],
)
async def test_table_format_validation(self) -> None:
"""Unknown ``table_format`` raises :class:`ValueError`."""
with self.assertRaises(ValueError):
PPTParser(table_format="csv") # type: ignore[arg-type]
async def test_string_input_treated_as_path(self) -> None:
"""``str`` is interpreted as a filesystem path to the PPTX."""
import tempfile
pptx_bytes = _make_pptx_simple(["Alpha"])
with tempfile.NamedTemporaryFile(
suffix=".pptx",
delete=False,
) as f:
f.write(pptx_bytes)
path = f.name
try:
parser = PPTParser(include_image=False)
sections = await parser.parse(path, "demo.pptx")
self.assertEqual(len(sections), 1)
finally:
os.unlink(path)
async def test_missing_path_raises_file_not_found(self) -> None:
"""A ``str`` pointing at a missing path raises FileNotFoundError."""
parser = PPTParser()
with self.assertRaises(FileNotFoundError):
await parser.parse("/no/such/file.pptx", "x.pptx")
class ExcelParserTest(IsolatedAsyncioTestCase):
"""Behavioural coverage for :class:`ExcelParser`."""
async def test_single_sheet_markdown(self) -> None:
"""A single-sheet workbook produces one text Section with
Markdown table."""
xlsx_bytes = _make_xlsx_simple(
{"Data": [["Name", "Age"], ["Alice", "25"]]},
)
parser = ExcelParser(table_format="markdown")
sections = await parser.parse(xlsx_bytes, "demo.xlsx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": (
"Sheet: Data\n"
"| Name | Age |\n"
"| --- | --- |\n"
"| Alice | 25 |\n"
),
"id": AnyString(),
},
"source": "demo.xlsx",
"metadata": {},
},
],
)
async def test_single_sheet_json(self) -> None:
"""``table_format="json"`` emits JSON rows."""
xlsx_bytes = _make_xlsx_simple(
{"Sheet1": [["X", "Y"], ["1", "2"]]},
)
parser = ExcelParser(table_format="json")
sections = await parser.parse(xlsx_bytes, "demo.xlsx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": (
"Sheet: Sheet1\n"
"<system-info>A table loaded as a JSON "
"array:</system-info>\n"
'["X", "Y"]\n'
'["1", "2"]'
),
"id": AnyString(),
},
"source": "demo.xlsx",
"metadata": {},
},
],
)
async def test_multi_sheet_merged_by_default(self) -> None:
"""``separate_sheet=False`` (default) merges sheets into one
Section."""
xlsx_bytes = _make_xlsx_simple(
{
"Sales": [["Q", "Rev"], ["Q1", "100"]],
"Costs": [["Q", "Cost"], ["Q1", "50"]],
},
)
parser = ExcelParser()
sections = await parser.parse(xlsx_bytes, "multi.xlsx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": (
"Sheet: Sales\n"
"| Q | Rev |\n"
"| --- | --- |\n"
"| Q1 | 100 |\n\n"
"Sheet: Costs\n"
"| Q | Cost |\n"
"| --- | --- |\n"
"| Q1 | 50 |\n"
),
"id": AnyString(),
},
"source": "multi.xlsx",
"metadata": {},
},
],
)
async def test_multi_sheet_separated(self) -> None:
"""``separate_sheet=True`` keeps each sheet as its own Section."""
xlsx_bytes = _make_xlsx_simple(
{
"Sales": [["Q", "Rev"], ["Q1", "100"]],
"Costs": [["Q", "Cost"], ["Q1", "50"]],
},
)
parser = ExcelParser(separate_sheet=True)
sections = await parser.parse(xlsx_bytes, "multi.xlsx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": (
"Sheet: Sales\n"
"| Q | Rev |\n"
"| --- | --- |\n"
"| Q1 | 100 |\n"
),
"id": AnyString(),
},
"source": "multi.xlsx",
"metadata": {"sheet": "Sales"},
},
{
"content": {
"type": "text",
"text": (
"Sheet: Costs\n"
"| Q | Cost |\n"
"| --- | --- |\n"
"| Q1 | 50 |\n"
),
"id": AnyString(),
},
"source": "multi.xlsx",
"metadata": {"sheet": "Costs"},
},
],
)
async def test_sheet_name_in_text(self) -> None:
"""``include_sheet_names=True`` prepends the sheet name."""
xlsx_bytes = _make_xlsx_simple(
{"MySheet": [["A"], ["1"]]},
)
parser = ExcelParser(include_sheet_names=True)
sections = await parser.parse(xlsx_bytes, "demo.xlsx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": (
"Sheet: MySheet\n" "| A |\n" "| --- |\n" "| 1 |\n"
),
"id": AnyString(),
},
"source": "demo.xlsx",
"metadata": {},
},
],
)
async def test_sheet_name_excluded(self) -> None:
"""``include_sheet_names=False`` omits the sheet header."""
xlsx_bytes = _make_xlsx_simple(
{"MySheet": [["A"], ["1"]]},
)
parser = ExcelParser(include_sheet_names=False)
sections = await parser.parse(xlsx_bytes, "demo.xlsx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": ("| A |\n" "| --- |\n" "| 1 |\n"),
"id": AnyString(),
},
"source": "demo.xlsx",
"metadata": {},
},
],
)
async def test_cell_coordinates(self) -> None:
"""``include_cell_coordinates=True`` adds coordinate prefixes."""
xlsx_bytes = _make_xlsx_simple(
{"Data": [["Val"], ["X"]]},
)
parser = ExcelParser(include_cell_coordinates=True)
sections = await parser.parse(xlsx_bytes, "demo.xlsx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": (
"Sheet: Data\n"
"| [A1] Val |\n"
"| --- |\n"
"| [A2] X |\n"
),
"id": AnyString(),
},
"source": "demo.xlsx",
"metadata": {},
},
],
)
async def test_string_input_treated_as_path(self) -> None:
"""``str`` is interpreted as a filesystem path."""
import tempfile
xlsx_bytes = _make_xlsx_simple(
{"S": [["Col"], ["Val"]]},
)
with tempfile.NamedTemporaryFile(
suffix=".xlsx",
delete=False,
) as f:
f.write(xlsx_bytes)
path = f.name
try:
parser = ExcelParser()
sections = await parser.parse(path, "demo.xlsx")
self.assertGreaterEqual(len(sections), 1)
finally:
os.unlink(path)
async def test_supported_extensions(self) -> None:
"""Both ``.xls`` and ``.xlsx`` are listed."""
exts = ExcelParser.supported_extensions()
self.assertIn(".xlsx", exts)
self.assertIn(".xls", exts)
async def test_table_format_validation(self) -> None:
"""Unknown ``table_format`` raises :class:`ValueError`."""
with self.assertRaises(ValueError):
ExcelParser(table_format="csv") # type: ignore[arg-type]
class WordParserTest(IsolatedAsyncioTestCase):
"""Behavioural coverage for :class:`WordParser`."""
async def test_simple_paragraphs(self) -> None:
"""Plain paragraphs are merged into a single text Section."""
docx_bytes = _make_docx_simple(["Hello", "World"])
parser = WordParser(include_image=False)
sections = await parser.parse(docx_bytes, "demo.docx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "Hello\nWorld",
"id": AnyString(),
},
"source": "demo.docx",
"metadata": {},
},
],
)
async def test_table_merges_by_default(self) -> None:
"""``separate_table=False`` merges the table into surrounding
text."""
docx_bytes = _make_docx_with_table()
parser = WordParser(include_image=False, separate_table=False)
sections = await parser.parse(docx_bytes, "demo.docx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": (
"Before table\n"
"| A | B |\n"
"| --- | --- |\n"
"| 1 | 2 |\n\n"
"After table"
),
"id": AnyString(),
},
"source": "demo.docx",
"metadata": {},
},
],
)
async def test_table_separated(self) -> None:
"""``separate_table=True`` isolates the table from surrounding
paragraphs."""
docx_bytes = _make_docx_with_table()
parser = WordParser(include_image=False, separate_table=True)
sections = await parser.parse(docx_bytes, "demo.docx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "Before table",
"id": AnyString(),
},
"source": "demo.docx",
"metadata": {},
},
{
"content": {
"type": "text",
"text": (
"| A | B |\n" "| --- | --- |\n" "| 1 | 2 |\n"
),
"id": AnyString(),
},
"source": "demo.docx",
"metadata": {},
},
{
"content": {
"type": "text",
"text": "After table",
"id": AnyString(),
},
"source": "demo.docx",
"metadata": {},
},
],
)
async def test_table_json_format(self) -> None:
"""``table_format="json"`` emits JSON instead of Markdown."""
docx_bytes = _make_docx_with_table()
parser = WordParser(
include_image=False,
separate_table=True,
table_format="json",
)
sections = await parser.parse(docx_bytes, "demo.docx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "Before table",
"id": AnyString(),
},
"source": "demo.docx",
"metadata": {},
},
{
"content": {
"type": "text",
"text": (
"<system-info>A table loaded as a JSON "
"array:</system-info>\n"
'[["A", "B"], ["1", "2"]]'
),
"id": AnyString(),
},
"source": "demo.docx",
"metadata": {},
},
{
"content": {
"type": "text",
"text": "After table",
"id": AnyString(),
},
"source": "demo.docx",
"metadata": {},
},
],
)
async def test_image_emits_data_block(self) -> None:
"""Embedded images become DataBlock sections."""
docx_bytes = _make_docx_with_image()
parser = WordParser(include_image=True)
sections = await parser.parse(docx_bytes, "rich.docx")
data_sections = [s for s in sections if s.content.type == "data"]
self.assertGreaterEqual(len(data_sections), 1)
ds = data_sections[0]
self.assertEqual(ds.source, "rich.docx")
self.assertEqual(ds.content.name, "rich.docx")
self.assertIn("media_type", ds.metadata)
async def test_image_excluded_when_disabled(self) -> None:
"""``include_image=False`` keeps only text sections."""
docx_bytes = _make_docx_with_image()
parser = WordParser(include_image=False)
sections = await parser.parse(docx_bytes, "rich.docx")
self.assertEqual(
[s.model_dump() for s in sections],
[
{
"content": {
"type": "text",
"text": "Text before image\nText after image",
"id": AnyString(),
},
"source": "rich.docx",
"metadata": {},
},
],
)
async def test_string_input_treated_as_path(self) -> None:
"""``str`` is interpreted as a filesystem path to the DOCX."""
import tempfile
docx_bytes = _make_docx_simple(["Alpha"])
with tempfile.NamedTemporaryFile(
suffix=".docx",
delete=False,
) as f:
f.write(docx_bytes)
path = f.name
try:
parser = WordParser(include_image=False)
sections = await parser.parse(path, "demo.docx")
self.assertEqual(len(sections), 1)
finally:
os.unlink(path)
async def test_supported_extensions(self) -> None:
"""``.docx`` is the only extension."""
self.assertEqual(WordParser.supported_extensions(), [".docx"])
async def test_table_format_validation(self) -> None:
"""Unknown ``table_format`` raises :class:`ValueError`."""
with self.assertRaises(ValueError):
WordParser(table_format="csv") # type: ignore[arg-type]