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

709 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
import contextlib
import importlib
import inspect
import json
import os
import platform
import subprocess
import tempfile
import threading
from functools import lru_cache, wraps
from itertools import combinations
from typing import (
TYPE_CHECKING,
Any,
Callable,
Iterable,
Iterator,
List,
Optional,
Tuple,
TypeVar,
cast,
)
import requests
from typing_extensions import ParamSpec, TypeAlias
from unstructured.__version__ import __version__
if TYPE_CHECKING:
from unstructured.documents.elements import Element, Text
# Box format: [x_bottom_left, y_bottom_left, x_top_right, y_top_right]
Box: TypeAlias = Tuple[float, float, float, float]
Point: TypeAlias = Tuple[float, float]
Points: TypeAlias = Tuple[Point, ...]
DATE_FORMATS = ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d+%H:%M:%S", "%Y-%m-%dT%H:%M:%S%z")
_T = TypeVar("_T")
_P = ParamSpec("_P")
def get_call_args_applying_defaults(
func: Callable[_P, List[Element]],
*args: _P.args,
**kwargs: _P.kwargs,
) -> dict[str, Any]:
"""Map both explicit and default arguments of decorated func call by param name."""
sig = inspect.signature(func)
call_args: dict[str, Any] = dict(**dict(zip(sig.parameters, args)), **kwargs)
for arg in sig.parameters.values():
if arg.name not in call_args and arg.default is not arg.empty:
call_args[arg.name] = arg.default
return call_args
def is_temp_file_path(file_path: str) -> bool:
"""True when file_path is in the Python-defined tempdir.
The Python-defined temp directory is platform dependent (macOS != Linux != Windows)
and can also be determined by an environment variable (TMPDIR, TEMP, or TMP).
"""
return file_path.startswith(tempfile.gettempdir())
def save_as_jsonl(data: list[dict[str, Any]], filename: str) -> None:
with open(filename, "w+") as output_file:
output_file.writelines(json.dumps(datum) + "\n" for datum in data)
def read_from_jsonl(filename: str) -> list[dict[str, Any]]:
with open(filename) as input_file:
return [json.loads(line) for line in input_file]
def requires_dependencies(
dependencies: str | list[str],
extras: Optional[str] = None,
) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]:
if isinstance(dependencies, str):
dependencies = [dependencies]
def decorator(func: Callable[_P, _T]) -> Callable[_P, _T]:
def run_check():
missing_deps: List[str] = []
for dep in dependencies:
if not dependency_exists(dep):
missing_deps.append(dep)
if len(missing_deps) > 0:
raise ImportError(
f"Following dependencies are missing: {', '.join(missing_deps)}. "
+ (
f"""Please install them using `pip install "unstructured[{extras}]"`."""
if extras
else f"Please install them using `pip install {' '.join(missing_deps)}`."
),
)
@wraps(func)
def wrapper(*args: _P.args, **kwargs: _P.kwargs):
run_check()
return func(*args, **kwargs)
@wraps(func)
async def wrapper_async(*args: _P.args, **kwargs: _P.kwargs):
run_check()
return await func(*args, **kwargs)
if asyncio.iscoroutinefunction(func):
return wrapper_async
return wrapper
return decorator
@lru_cache(maxsize=128)
def dependency_exists(dependency: str):
try:
importlib.import_module(dependency)
except ImportError as e:
# Check to make sure this isn't some unrelated import error.
if dependency in repr(e):
return False
return True
def _first_and_remaining_iterator(it: Iterable[_T]) -> Tuple[_T, Iterator[_T]]:
iterator = iter(it)
try:
out = next(iterator)
except StopIteration:
raise ValueError(
"Expected at least 1 element in iterable from which to retrieve first, got empty "
"iterable.",
)
return out, iterator
def first(it: Iterable[_T]) -> _T:
"""Returns the first item from an iterable. Raises an error if the iterable is empty."""
out, _ = _first_and_remaining_iterator(it)
return out
def only(it: Iterable[Any]) -> Any:
"""Returns the only element from a singleton iterable.
Raises an error if the iterable is not a singleton.
"""
out, iterator = _first_and_remaining_iterator(it)
if any(True for _ in iterator):
raise ValueError(
"Expected only 1 element in passed argument, instead there are at least 2 elements.",
)
return out
def _telemetry_opt_out() -> bool:
"""True if telemetry should be disabled via env.
DO_NOT_TRACK and SCARF_NO_ANALYTICS both follow the same rule: any non-empty
value (after strip) opts out. See README/CHANGELOG for the public contract.
"""
return bool((os.getenv("DO_NOT_TRACK") or "").strip()) or bool(
(os.getenv("SCARF_NO_ANALYTICS") or "").strip()
)
def _telemetry_opt_in() -> bool:
"""True if telemetry is explicitly enabled via env. Only 'true' and '1' opt in."""
return (os.getenv("UNSTRUCTURED_TELEMETRY_ENABLED") or "").strip().lower() in (
"true",
"1",
)
def scarf_analytics():
"""Send a lightweight analytics ping. Off by default.
Set UNSTRUCTURED_TELEMETRY_ENABLED=true to opt in.
Opt-out env vars (DO_NOT_TRACK, SCARF_NO_ANALYTICS): any non-empty value opts out.
"""
if _telemetry_opt_out() or not _telemetry_opt_in():
return
try:
subprocess.check_output(["nvidia-smi"], stderr=subprocess.DEVNULL)
gpu_present = True
except (OSError, subprocess.CalledProcessError):
gpu_present = False
python_version = ".".join(platform.python_version().split(".")[:2])
with contextlib.suppress(Exception):
requests.get(
"https://packages.unstructured.io/python-telemetry",
params={
"version": __version__,
"platform": platform.system(),
"python": python_version,
"arch": platform.machine(),
"gpu": str(gpu_present),
"dev": str("dev" in __version__).lower(),
},
timeout=10,
)
def ngrams(s: list[str], n: int) -> list[tuple[str, ...]]:
"""Generate n-grams from a list of strings where `n` (int) is the size of each n-gram."""
if n <= 0:
raise ValueError(f"n must be positive, received n = {n}")
return [tuple(s[i : i + n]) for i in range(len(s) - n + 1)]
def calculate_shared_ngram_percentage(
first_string: str,
second_string: str,
n: int,
) -> tuple[float, set[tuple[str, ...]]]:
"""Calculate the percentage of common_ngrams between string A and B with reference to A"""
if not n:
return 0, set()
first_string_ngrams = ngrams(first_string.split(), n)
second_string_ngrams = ngrams(second_string.split(), n)
if not first_string_ngrams:
return 0, set()
common_ngrams = set(first_string_ngrams) & set(second_string_ngrams)
percentage = (len(common_ngrams) / len(first_string_ngrams)) * 100
return percentage, common_ngrams
def calculate_largest_ngram_percentage(
first_string: str, second_string: str
) -> tuple[float, set[tuple[str, ...]], str]:
"""From two strings, calculate the shared ngram percentage.
Returns a tuple containing...
- The largest n-gram percentage shared between the two strings.
- A set containing the shared n-grams found during the calculation.
- A string representation of the size of the largest shared n-grams found.
"""
shared_ngrams: set[tuple[str, ...]] = set()
if len(first_string.split()) < len(second_string.split()):
n = len(first_string.split()) - 1
else:
n = len(second_string.split()) - 1
first_string, second_string = second_string, first_string
ngram_percentage = 0
# Start from the biggest ngram possible (`n`) until the ngram_percentage is >0.0% or n == 0
while not ngram_percentage:
ngram_percentage, shared_ngrams = calculate_shared_ngram_percentage(
first_string,
second_string,
n,
)
if n == 0:
break
else:
n -= 1
return round(ngram_percentage, 2), shared_ngrams, str(n + 1)
def is_parent_box(parent_target: Box, child_target: Box, add: float = 0.0) -> bool:
"""True if the child_target bounding box is nested in the parent_target.
Box format: [x_bottom_left, y_bottom_left, x_top_right, y_top_right].
The parameter 'add' is the pixel error tolerance for extra pixels outside the parent region
"""
if len(parent_target) != 4:
return False
parent_targets = [0, 0, 0, 0]
if add and len(parent_target) == 4:
parent_targets = list(parent_target)
parent_targets[0] -= add
parent_targets[1] -= add
parent_targets[2] += add
parent_targets[3] += add
if (
len(child_target) == 4
and (child_target[0] >= parent_targets[0] and child_target[1] >= parent_targets[1])
and (child_target[2] <= parent_targets[2] and child_target[3] <= parent_targets[3])
):
return True
return len(child_target) == 2 and (
parent_targets[0] <= child_target[0] <= parent_targets[2]
and parent_targets[1] <= child_target[1] <= parent_targets[3]
)
def calculate_overlap_percentage(
box1: Points,
box2: Points,
intersection_ratio_method: str = "total",
) -> tuple[float, float, float, float]:
"""Calculate the percentage of overlapped region.
Calculate the percentage with reference to
the biggest element-region (intersection_ratio_method="parent"),
the smallest element-region (intersection_ratio_method="partial"), or
the disjunctive union region (intersection_ratio_method="total")
"""
x1, y1 = box1[0]
x2, y2 = box1[2]
x3, y3 = box2[0]
x4, y4 = box2[2]
area_box1 = (x2 - x1) * (y2 - y1)
area_box2 = (x4 - x3) * (y4 - y3)
x_intersection1 = max(x1, x3)
y_intersection1 = max(y1, y3)
x_intersection2 = min(x2, x4)
y_intersection2 = min(y2, y4)
intersection_area = max(0, x_intersection2 - x_intersection1) * max(
0,
y_intersection2 - y_intersection1,
)
max_area = max(area_box1, area_box2)
min_area = min(area_box1, area_box2)
total_area = area_box1 + area_box2
if intersection_ratio_method == "parent":
if max_area == 0:
return 0, 0, 0, 0
overlap_percentage = (intersection_area / max_area) * 100
elif intersection_ratio_method == "partial":
if min_area == 0:
return 0, 0, 0, 0
overlap_percentage = (intersection_area / min_area) * 100
else:
if (area_box1 + area_box2) == 0:
return 0, 0, 0, 0
overlap_percentage = (intersection_area / (area_box1 + area_box2 - intersection_area)) * 100
return round(overlap_percentage, 2), max_area, min_area, total_area
def identify_overlapping_case(
box_pair: list[Points] | tuple[Points, Points],
label_pair: list[str] | tuple[str, str],
text_pair: list[str] | tuple[str, str],
ix_pair: list[str] | tuple[str, str],
sm_overlap_threshold: float = 10.0,
):
"""Classifies the overlapping case for an element_pair input.
There are 5 cases of overlapping:
'Small partial overlap'
'Partial overlap with empty content'
'Partial overlap with duplicate text (sharing 100% of the text)'
'Partial overlap without sharing text'
'Partial overlap sharing {calculate_largest_ngram_percentage(...)}% of the text'
Returns:
overlapping_elements: List[str] - List of element types with their `ix` value.
Ex: ['Title(ix=0)']
overlapping_case: str - See list of cases above
overlap_percentage: float
largest_ngram_percentage: float
max_area: float
min_area: float
total_area: float
"""
overlapping_elements, overlapping_case, overlap_percentage, largest_ngram_percentage = (
None,
None,
None,
None,
)
box1, box2 = box_pair
type1, type2 = label_pair
text1, text2 = text_pair
ix_element1, ix_element2 = ix_pair
overlap_percentage, max_area, min_area, total_area = calculate_overlap_percentage(
box1,
box2,
intersection_ratio_method="partial",
)
if overlap_percentage < sm_overlap_threshold:
overlapping_elements = [
f"{type1}(ix={ix_element1})",
f"{type2}(ix={ix_element2})",
]
overlapping_case = "Small partial overlap"
else:
if not text1:
overlapping_elements = [
f"{type1}(ix={ix_element1})",
f"{type2}(ix={ix_element2})",
]
overlapping_case = f"partial overlap with empty content in {type1}"
elif not text2:
overlapping_elements = [
f"{type2}(ix={ix_element2})",
f"{type1}(ix={ix_element1})",
]
overlapping_case = f"partial overlap with empty content in {type2}"
elif text1 in text2 or text2 in text1:
overlapping_elements = [
f"{type1}(ix={ix_element1})",
f"{type2}(ix={ix_element2})",
]
overlapping_case = "partial overlap with duplicate text"
else:
largest_ngram_percentage, _, largest_n = calculate_largest_ngram_percentage(
text1, text2
)
largest_ngram_percentage = round(largest_ngram_percentage, 2)
if not largest_ngram_percentage:
overlapping_elements = [
f"{type1}(ix={ix_element1})",
f"{type2}(ix={ix_element2})",
]
overlapping_case = "partial overlap without sharing text"
else:
overlapping_elements = [
f"{type1}(ix={ix_element1})",
f"{type2}(ix={ix_element2})",
]
ref_type = type1 if len(text1.split()) < len(text2.split()) else type2
ref_type = "of the text from" + ref_type + f"({largest_n}-gram)"
overlapping_case = f"partial overlap sharing {largest_ngram_percentage}% {ref_type}"
return (
overlapping_elements,
overlapping_case,
overlap_percentage,
largest_ngram_percentage,
max_area,
min_area,
total_area,
)
def _convert_coordinates_to_box(coordinates: Points):
"""Accepts a set of Points and returns the lower-left and upper-right coordinates.
Expects four coordinates representing the corners of a rectangle, listed in this order:
bottom-left, top-left, top-right, bottom-right.
"""
x_bottom_left_1, y_bottom_left_1 = coordinates[0]
x_top_right_1, y_top_right_1 = coordinates[2]
return x_bottom_left_1, y_bottom_left_1, x_top_right_1, y_top_right_1
# x1, y1 = box1[0]
def identify_overlapping_or_nesting_case(
box_pair: list[Points] | tuple[Points, Points],
label_pair: list[str] | tuple[str, str],
text_pair: list[str] | tuple[str, str],
nested_error_tolerance_px: int = 5,
sm_overlap_threshold: float = 10.0,
):
"""Identify if overlapping or nesting elements exist and, if so, the type of overlapping case.
Returns:
overlapping_elements: List[str] - List of element types & their `ix` value. Ex: ['Title(ix=0)']
overlapping_case: str - See list of cases above
overlap_percentage: float
overlap_percentage_total: float
largest_ngram_percentage: float
max_area: float
min_area: float
total_area: float
"""
box1, box2 = box_pair
type1, type2 = label_pair
ix_element1 = "".join([ch for ch in type1 if ch.isnumeric()])
ix_element2 = "".join([ch for ch in type2 if ch.isnumeric()])
type1 = type1[3:].strip()
type2 = type2[3:].strip()
box1_corners = _convert_coordinates_to_box(box1)
box2_corners = _convert_coordinates_to_box(box2)
x_bottom_left_1, y_bottom_left_1, x_top_right_1, y_top_right_1 = box1_corners
x_bottom_left_2, y_bottom_left_2, x_top_right_2, y_top_right_2 = box2_corners
horizontal_overlap = x_bottom_left_1 < x_top_right_2 and x_top_right_1 > x_bottom_left_2
vertical_overlap = y_bottom_left_1 < y_top_right_2 and y_top_right_1 > y_bottom_left_2
(
overlapping_elements,
parent_element,
overlapping_case,
overlap_percentage,
overlap_percentage_total,
largest_ngram_percentage,
) = (
None,
None,
None,
None,
None,
None,
)
max_area, min_area, total_area = None, None, None
if horizontal_overlap and vertical_overlap:
overlap_percentage_total, _, _, _ = calculate_overlap_percentage(
box1,
box2,
intersection_ratio_method="total",
)
overlap_percentage, max_area, min_area, total_area = calculate_overlap_percentage(
box1,
box2,
intersection_ratio_method="parent",
)
if is_parent_box(box1_corners, box2_corners, add=nested_error_tolerance_px):
overlapping_elements = [
f"{type1}(ix={ix_element1})",
f"{type2}(ix={ix_element2})",
]
overlapping_case = f"nested {type2} in {type1}"
overlap_percentage = 100
parent_element = f"{type1}(ix={ix_element1})"
elif is_parent_box(box2_corners, box1_corners, add=nested_error_tolerance_px):
overlapping_elements = [
f"{type2}(ix={ix_element2})",
f"{type1}(ix={ix_element1})",
]
overlapping_case = f"nested {type1} in {type2}"
overlap_percentage = 100
parent_element = f"{type2}(ix={ix_element2})"
else:
(
overlapping_elements,
overlapping_case,
overlap_percentage,
largest_ngram_percentage,
max_area,
min_area,
total_area,
) = identify_overlapping_case(
box_pair,
label_pair,
text_pair,
(ix_element1, ix_element2),
sm_overlap_threshold=sm_overlap_threshold,
)
return (
overlapping_elements,
parent_element,
overlapping_case,
overlap_percentage or 0,
overlap_percentage_total or 0,
largest_ngram_percentage or 0,
max_area or 0,
min_area or 0,
total_area or 0,
)
def catch_overlapping_and_nested_bboxes(
elements: list["Text"],
nested_error_tolerance_px: int = 5,
sm_overlap_threshold: float = 10.0,
) -> tuple[bool, list[dict[str, Any]]]:
"""Catch overlapping and nested bounding boxes cases across a list of elements."""
num_pages = elements[-1].metadata.page_number or 0
pages_of_bboxes: list[list[Points]] = [[] for _ in range(num_pages)]
text_labels: list[list[str]] = [[] for _ in range(num_pages)]
text_content: list[list[str]] = [[] for _ in range(num_pages)]
for ix, element in enumerate(elements):
page_number = element.metadata.page_number or 1
n_page_to_ix = page_number - 1
if element.metadata.coordinates:
box = cast(Points, element.metadata.coordinates.to_dict()["points"])
pages_of_bboxes[n_page_to_ix].append(box)
text_labels[n_page_to_ix].append(f"{ix}. {element.category}")
text_content[n_page_to_ix].append(element.text)
document_with_overlapping_flag = False
overlapping_cases: list[dict[str, Any]] = []
for page_number, (page_bboxes, page_labels, page_text) in enumerate(
zip(pages_of_bboxes, text_labels, text_content),
start=1,
):
page_bboxes_combinations = list(combinations(page_bboxes, 2))
page_labels_combinations = list(combinations(page_labels, 2))
text_content_combinations = list(combinations(page_text, 2))
for box_pair, label_pair, text_pair in zip(
page_bboxes_combinations,
page_labels_combinations,
text_content_combinations,
):
(
overlapping_elements,
parent_element,
overlapping_case,
overlap_percentage,
overlap_percentage_total,
largest_ngram_percentage,
max_area,
min_area,
total_area,
) = identify_overlapping_or_nesting_case(
box_pair,
label_pair,
text_pair,
nested_error_tolerance_px,
sm_overlap_threshold,
)
if overlapping_case:
overlapping_cases.append(
{
"overlapping_elements": overlapping_elements,
"parent_element": parent_element,
"overlapping_case": overlapping_case,
"overlap_percentage": f"{overlap_percentage}%",
"metadata": {
"largest_ngram_percentage": largest_ngram_percentage,
"overlap_percentage_total": f"{overlap_percentage_total}%",
"max_area": f"{round(max_area, 2)}pxˆ2",
"min_area": f"{round(min_area, 2)}pxˆ2",
"total_area": f"{round(total_area, 2)}pxˆ2",
},
},
)
document_with_overlapping_flag = True
return document_with_overlapping_flag, overlapping_cases
def group_elements_by_parent_id(
elements: Iterable["Element"],
assign_orphans: bool = False,
) -> dict[Optional[str], list["Element"]]:
"""Group elements by their parent_id metadata field.
Elements with the same parent_id are grouped together.
Args:
elements: An iterable of Element objects to group.
assign_orphans: If True, elements with no parent_id (None) will be assigned to
the same group as the previous element. If False (default), elements with
no parent are grouped under the None key.
Returns:
A dictionary mapping parent_id values to lists of elements sharing that parent_id.
Example:
>>> elements = partition("example.pdf")
>>> grouped = group_elements_by_parent_id(elements)
>>> for parent_id, children in grouped.items():
... print(f"Parent {parent_id}: {len(children)} children")
>>> # Assign orphan elements to previous element's group
>>> grouped = group_elements_by_parent_id(elements, assign_orphans=True)
"""
from collections import defaultdict
groups: dict[Optional[str], list["Element"]] = defaultdict(list)
last_parent_id: Optional[str] = None
for element in elements:
parent_id = getattr(element.metadata, "parent_id", None)
if parent_id is None and assign_orphans:
parent_id = last_parent_id
elif parent_id is not None:
last_parent_id = parent_id
groups[parent_id].append(element)
return dict(groups)
class FileHandler:
def __init__(self, file_path: str):
self.file_path = file_path
self.lock = threading.Lock()
def read_file(self):
with self.lock:
with open(self.file_path) as file:
data = file.read()
return data
def write_file(self, data: str) -> None:
with self.lock:
with open(self.file_path, "w") as file:
file.write(data)
def cleanup_file(self):
with self.lock:
if os.path.exists(self.file_path):
os.remove(self.file_path)