Files
wehub-resource-sync 461bf6fd40
CI / lint (3.11) (push) Blocked by required conditions
CI / lint (3.12) (push) Blocked by required conditions
CI / lint (3.13) (push) Blocked by required conditions
CI / shellcheck (push) Waiting to run
CI / shfmt (push) Waiting to run
CI / setup (3.11) (push) Waiting to run
CI / setup (3.12) (push) Waiting to run
CI / setup (3.13) (push) Waiting to run
CI / check-licenses (3.12) (push) Blocked by required conditions
CI / test_unit (3.11) (push) Blocked by required conditions
CI / test_unit (3.12) (push) Blocked by required conditions
CI / test_unit (3.13) (push) Blocked by required conditions
CI / test_unit_no_extras (3.11) (push) Blocked by required conditions
CI / test_unit_no_extras (3.12) (push) Blocked by required conditions
CI / test_json_to_html (3.12) (push) Blocked by required conditions
CI / test_unit_no_extras (3.13) (push) Blocked by required conditions
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Blocked by required conditions
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Blocked by required conditions
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Blocked by required conditions
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Blocked by required conditions
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Blocked by required conditions
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Blocked by required conditions
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Blocked by required conditions
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Blocked by required conditions
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Blocked by required conditions
CI / test_ingest_src (3.12) (push) Blocked by required conditions
CI / test_json_to_markdown (3.12) (push) Blocked by required conditions
CI / changelog (push) Waiting to run
CI / test_dockerfile (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Build And Push Docker Image / set-short-sha (push) Waiting to run
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Blocked by required conditions
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Build And Push Docker Image / publish-images (push) Blocked by required conditions
Partition Benchmark / setup (push) Waiting to run
Partition Benchmark / Measure and compare partition() runtime (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:33:56 +08:00

358 lines
12 KiB
Python

# pyright: reportPrivateUsage=false
"""Regression and characterization tests for table isolation during chunking (issue #3921).
`Table` (and `TableChunk`, which subclasses `Table`) must never share a pre-chunk with unrelated
text elements, so downstream logic can emit standalone table chunks instead of `CompositeElement`
wrapping mixed content.
"""
from __future__ import annotations
from typing import Callable
import pytest
from unstructured.chunking.base import (
ChunkingOptions,
PreChunk,
PreChunkBuilder,
PreChunkCombiner,
PreChunker,
)
from unstructured.chunking.basic import chunk_elements
from unstructured.chunking.title import chunk_by_title
from unstructured.documents.elements import (
CompositeElement,
ElementMetadata,
Table,
TableChunk,
Text,
Title,
)
class DescribeTableIsolationPreChunkBuilder:
"""`PreChunkBuilder` must keep every table-family element in its own pre-chunk."""
@pytest.mark.parametrize(
("preamble", "make_table"),
[
(Text("Short preamble."), lambda: Table("H\nC")),
(Text("Short preamble."), lambda: TableChunk(text="x", metadata=ElementMetadata())),
],
)
def it_refuses_to_append_a_table_after_any_other_element(
self, preamble: Text, make_table: Callable[[], Table]
):
builder = PreChunkBuilder(opts=ChunkingOptions(max_characters=500))
builder.add_element(preamble)
assert not builder.will_fit(make_table())
@pytest.mark.parametrize(
"trailing",
[
Text("Follow-up paragraph."),
Title("Next section"),
],
)
def it_refuses_to_append_a_non_table_after_a_table(self, trailing: Text | Title):
builder = PreChunkBuilder(opts=ChunkingOptions(max_characters=500))
builder.add_element(Table("Heading\nCell"))
assert not builder.will_fit(trailing)
def it_allows_only_a_table_when_the_builder_is_empty(self):
builder = PreChunkBuilder(opts=ChunkingOptions())
assert builder.will_fit(Table("Heading\nCell text"))
def it_allows_text_after_flush_even_if_previous_pre_chunk_was_a_table(self):
opts = ChunkingOptions(max_characters=200)
builder = PreChunkBuilder(opts=opts)
builder.add_element(Table("Heading\nCell text"))
list(builder.flush()) # clears builder state
assert builder.will_fit(Text("Fresh start after table pre-chunk."))
class DescribeTableIsolationPreChunkStream:
"""End-to-end pre-chunk segmentation from `PreChunker.iter_pre_chunks`."""
def it_emits_a_table_only_pre_chunk_between_text_blocks(self):
elements = [
Title("Section A"),
Text("Narrative before the table."),
Table("Col1\nCell A"),
Text("Narrative after the table."),
]
# -- `new_after_n_chars=0` forces one element per pre-chunk (deterministic layout) --
opts = ChunkingOptions(max_characters=500, new_after_n_chars=0)
pre_chunks = list(PreChunker.iter_pre_chunks(elements, opts=opts))
assert len(pre_chunks) == 4
assert pre_chunks[0]._elements == [Title("Section A")]
assert pre_chunks[1]._elements == [Text("Narrative before the table.")]
assert pre_chunks[2]._elements == [Table("Col1\nCell A")]
assert pre_chunks[3]._elements == [Text("Narrative after the table.")]
def it_emits_one_pre_chunk_per_table_when_multiple_tables_are_adjacent(self):
elements = [
Table("T1\nA"),
Table("T2\nB"),
Text("Closing text."),
]
opts = ChunkingOptions(max_characters=500)
pre_chunks = list(PreChunker.iter_pre_chunks(elements, opts=opts))
assert len(pre_chunks) == 3
assert pre_chunks[0]._elements == [Table("T1\nA")]
assert pre_chunks[1]._elements == [Table("T2\nB")]
assert pre_chunks[2]._elements == [Text("Closing text.")]
class DescribeTableIsolationPreChunkCombiner:
"""`PreChunkCombiner` must not stitch table pre-chunks onto text neighbors."""
def it_keeps_a_table_pre_chunk_separate_when_combining_is_enabled(self):
opts = ChunkingOptions(max_characters=500, combine_text_under_n_chars=500)
stream = [
PreChunk([Text("Hello world.")], overlap_prefix="", opts=opts),
PreChunk([Table("H\nC")], overlap_prefix="", opts=opts),
PreChunk([Text("Goodbye world.")], overlap_prefix="", opts=opts),
]
combined = list(PreChunkCombiner(stream, opts=opts).iter_combined_pre_chunks())
assert len(combined) == 3
assert combined[0]._elements == [Text("Hello world.")]
assert combined[1]._elements == [Table("H\nC")]
assert combined[2]._elements == [Text("Goodbye world.")]
class DescribeTableIsolationOrderingGuarantees:
"""Invariants that should hold for any future refactor of table isolation."""
def it_preserves_global_element_order_in_pre_chunks(self):
elements = [
Text("alpha"),
Table("T\n1"),
Text("beta"),
Table("T\n2"),
Title("gamma"),
]
opts = ChunkingOptions(max_characters=500, new_after_n_chars=0)
flat = [e for pc in PreChunker.iter_pre_chunks(elements, opts=opts) for e in pc._elements]
assert flat == elements
def it_preserves_global_element_order_in_chunk_elements_output(self):
elements = [
Title("Intro"),
Text("Body before."),
Table("K\nV"),
Text("Body after."),
]
chunks = chunk_elements(elements, max_characters=500, new_after_n_chars=0)
# -- flatten chunk element categories in stream order (table chunks are atomic) --
categories = [c.category for c in chunks]
assert categories == [
"CompositeElement",
"CompositeElement",
"Table",
"CompositeElement",
]
class DescribeTableIsolationChunkElements:
"""`chunk_elements` (basic strategy) should emit `Table`/`TableChunk`, not mixed composites."""
def it_does_not_wrap_a_table_and_surrounding_text_in_one_composite_element(self):
elements = [
Title("Report"),
Text("Short intro."),
Table("Key\nValue"),
Text("Short outro."),
]
chunks = chunk_elements(
elements,
max_characters=500,
new_after_n_chars=0,
)
assert len(chunks) == 4
assert isinstance(chunks[0], CompositeElement)
assert isinstance(chunks[1], CompositeElement)
assert isinstance(chunks[2], Table)
assert isinstance(chunks[3], CompositeElement)
assert "Key" in chunks[2].text or "Value" in chunks[2].text
def it_yields_distinct_chunks_for_two_tables_in_a_row(self):
elements = [
Table("T1\nA"),
Table("T2\nB"),
]
chunks = chunk_elements(elements, max_characters=500)
assert len(chunks) == 2
assert all(isinstance(c, Table) for c in chunks)
def it_still_isolates_a_table_even_when_the_window_is_very_large(self):
"""Regression: isolation is a semantic rule, not a size heuristic."""
elements = [
Text("x"),
Table("tiny"),
Text("y"),
]
chunks = chunk_elements(elements, max_characters=50_000, new_after_n_chars=10_000)
table_chunks = [c for c in chunks if isinstance(c, Table)]
composite_chunks = [c for c in chunks if isinstance(c, CompositeElement)]
assert len(table_chunks) == 1
assert len(composite_chunks) == 2
assert "tiny" in table_chunks[0].text
def it_never_produces_a_composite_element_that_lists_a_table_in_orig_elements(
self,
):
"""`CompositeElement` chunks come from `_Chunker`, not `_TableChunker`."""
elements = [
Text("preamble"),
Table("H\nC"),
Text("post"),
]
chunks = chunk_elements(
elements,
max_characters=400,
new_after_n_chars=0,
include_orig_elements=True,
)
composites = [c for c in chunks if isinstance(c, CompositeElement)]
for comp in composites:
orig = comp.metadata.orig_elements or []
assert not any(isinstance(e, Table) for e in orig)
class DescribeTableIsolationDisabled:
"""When `isolate_table=False`, the pre-#4307 behavior is restored."""
def it_lets_a_table_share_a_pre_chunk_with_adjacent_text_when_disabled(self):
opts = ChunkingOptions(max_characters=500, isolate_table=False)
builder = PreChunkBuilder(opts=opts)
builder.add_element(Text("Short preamble."))
assert builder.will_fit(Table("Heading\nCell text"))
def it_lets_text_follow_a_table_in_the_same_pre_chunk_when_disabled(self):
opts = ChunkingOptions(max_characters=500, isolate_table=False)
builder = PreChunkBuilder(opts=opts)
builder.add_element(Table("Heading\nCell text"))
assert builder.will_fit(Text("Follow-up paragraph."))
def it_combines_a_table_pre_chunk_with_text_neighbors_when_disabled(self):
opts = ChunkingOptions(
max_characters=500, combine_text_under_n_chars=500, isolate_table=False
)
stream = [
PreChunk([Text("Hello world.")], overlap_prefix="", opts=opts),
PreChunk([Table("H\nC")], overlap_prefix="", opts=opts),
PreChunk([Text("Goodbye world.")], overlap_prefix="", opts=opts),
]
combined = list(PreChunkCombiner(stream, opts=opts).iter_combined_pre_chunks())
assert len(combined) == 1
assert combined[0]._elements == [
Text("Hello world."),
Table("H\nC"),
Text("Goodbye world."),
]
def it_wraps_a_table_into_a_composite_element_with_neighbors_when_disabled(self):
"""End-to-end: opting out collapses tiny table + text into one CompositeElement."""
elements = [
Text("preamble"),
Table("H\nC"),
Text("post"),
]
chunks = chunk_elements(
elements,
max_characters=500,
isolate_table=False,
)
assert len(chunks) == 1
assert isinstance(chunks[0], CompositeElement)
text = chunks[0].text
assert "preamble" in text
assert "post" in text
class DescribeTableIsolationOverlapAll:
"""With overlap_all=True, overlap must not cross table / narrative boundaries."""
def it_does_not_prefix_table_chunk_with_prior_text_overlap(self):
"""Regression: pre-chunk overlap_tail must not become table's overlap_prefix."""
elements = [Text("Alpha beta gamma delta."), Table("H\nC")]
chunks = chunk_elements(
elements,
max_characters=500,
new_after_n_chars=0,
overlap=5,
overlap_all=True,
)
table_chunks = [c for c in chunks if isinstance(c, Table)]
assert len(table_chunks) == 1
t = table_chunks[0].text or ""
assert "Alpha" not in t
assert "elta" not in t # tail of "delta" leaked in buggy overlap
def it_does_not_prefix_text_after_table_with_table_overlap(self):
elements = [Table("H\nC"), Text("Omega sigma tau upsilon.")]
chunks = chunk_elements(
elements,
max_characters=500,
new_after_n_chars=0,
overlap=5,
overlap_all=True,
)
composites = [c for c in chunks if isinstance(c, CompositeElement)]
assert len(composites) == 1
assert composites[0].text.startswith("Omega")
assert "H" not in composites[0].text[:20]
def it_chunk_by_title_respects_same_overlap_boundaries(self):
elements = [
Title("Section"),
Text("Alpha beta gamma delta."),
Table("H\nC"),
Text("Omega sigma tau upsilon."),
]
chunks = chunk_by_title(
elements,
max_characters=500,
new_after_n_chars=0,
overlap=5,
overlap_all=True,
combine_text_under_n_chars=0,
)
table_chunks = [c for c in chunks if isinstance(c, Table)]
assert len(table_chunks) == 1
assert "Alpha" not in (table_chunks[0].text or "")
assert "elta" not in (table_chunks[0].text or "")
omega_composites = [
c for c in chunks if isinstance(c, CompositeElement) and "Omega" in (c.text or "")
]
assert len(omega_composites) == 1
assert omega_composites[0].text.startswith("Omega")