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

336 lines
14 KiB
Python

"""Helpers used across multiple partitioners to compute metadata."""
from __future__ import annotations
import copy
import datetime as dt
import functools
import os
from typing import Any, Callable, Iterator, Sequence
from typing_extensions import ParamSpec
from unstructured.documents.elements import Element, ElementMetadata, ListItem, Title
from unstructured.file_utils.model import FileType
from unstructured.partition.common.lang import apply_lang_metadata
from unstructured.utils import get_call_args_applying_defaults
_P = ParamSpec("_P")
def get_last_modified_date(filename: str) -> str | None:
"""Modification time of file at path `filename`, if it exists.
Returns `None` when `filename` is not a path to a file on the local filesystem.
Otherwise returns date and time in ISO 8601 string format (YYYY-MM-DDTHH:MM:SS) like
"2024-03-05T17:02:53".
"""
if not os.path.isfile(filename):
return None
modify_date = dt.datetime.fromtimestamp(os.path.getmtime(filename))
return modify_date.strftime("%Y-%m-%dT%H:%M:%S%z")
HIERARCHY_RULE_SET = {
"Title": [
"Text",
"UncategorizedText",
"NarrativeText",
"ListItem",
"BulletedText",
"Table",
"FigureCaption",
"CheckBox",
"Table",
],
"Header": [
"Title",
"Text",
"UncategorizedText",
"NarrativeText",
"ListItem",
"BulletedText",
"Table",
"FigureCaption",
"CheckBox",
"Table",
],
}
# Canonical HTML heading levels -> zero-indexed category_depth. The HTML spec
# defines exactly six heading levels (there is no h7), so this closed mapping is
# the single source of truth for both the depth value and the heading-tag set
# (HEADING_TAGS is derived from it, not a second copy).
_HEADING_DEPTH = {"h1": 0, "h2": 1, "h3": 2, "h4": 3, "h5": 4, "h6": 5}
HEADING_TAGS = tuple(_HEADING_DEPTH)
def category_depth_from_html_tag(
ElementCls: type[Element], tag: str | None, list_ancestor_count: int = 0
) -> int | None:
"""Compute `category_depth` from an element's HTML heading level (not DOM-nesting depth).
This is the canonical mapping used by both the v1 HTML parser and the v2 (ontology) HTML
converter so the two paths agree on what `category_depth` means:
- `Title` (which includes ontology Title/Subtitle/Heading, i.e. ``<h1>``-``<h6>``): the heading
level, zero-indexed -- ``h1`` -> 0, ``h2`` -> 1, ... ``h6`` -> 5. A `Title` whose tag is not a
heading (e.g. a styled paragraph) is treated as a top-level heading (0).
- `ListItem`: the number of enclosing list containers (``ol``/``ul``/``dl``), passed in by the
caller (the v1 HTML parser, which computes it from list nesting). The v2 converter serializes
a whole ``ol``/``ul``/``dl`` as one element and never emits a standalone ``ListItem``, so it
does not use this.
- Everything else: ``None`` (no meaningful depth).
`tag` is the element's HTML tag name (e.g. ``"h2"``); it may be ``None`` for derived elements.
"""
if ElementCls is ListItem:
return list_ancestor_count
if ElementCls is Title:
return _HEADING_DEPTH.get(tag, 0)
return None
def set_element_hierarchy(
elements: Sequence[Element], ruleset: dict[str, list[str]] = HIERARCHY_RULE_SET
) -> list[Element]:
"""Sets `.metadata.parent_id` for each element it applies to.
`parent_id` assignment is based on the element's category and depth. The importance of an
element's category is determined by a rule set. The rule set trumps category_depth. That is,
category_depth is only relevant when elements are of the same category.
"""
stack: list[Element] = []
for element in elements:
if element.metadata.parent_id is not None:
continue
parent_id = None
element_category = getattr(element, "category", None)
element_category_depth = getattr(element.metadata, "category_depth", 0) or 0
# -- skip elements without a category --
if not element_category:
continue
while stack:
top_element: Element = stack[-1]
top_element_category = getattr(top_element, "category")
top_element_category_depth = (
getattr(
top_element.metadata,
"category_depth",
0,
)
or 0
)
if (
top_element_category == element_category
and top_element_category_depth < element_category_depth
) or (
top_element_category != element_category
and element_category in ruleset.get(top_element_category, [])
):
parent_id = top_element.id
break
stack.pop()
element.metadata.parent_id = parent_id
stack.append(element)
return list(elements)
# ================================================================================================
# METADATA POST-PARTITIONING PROCESSING DECORATOR
# ================================================================================================
def apply_metadata(
file_type: FileType | None = None,
) -> Callable[[Callable[_P, list[Element]]], Callable[_P, list[Element]]]:
"""Post-process element-metadata for this document.
This decorator adds a post-processing step to a partitioner, primarily to apply metadata that
is common to all partitioners. It assumes the following responsibilities:
- Hash element-ids. Computes and applies SHA1 hash element.id when `unique_element_ids`
argument is False.
- Element Hierarchy. Computes and applies `parent_id` metadata based on `category_depth`
etc. added by partitioner.
- Language metadata. Computes and applies `language` metadata based on a language detection
model.
- Apply `filetype` (MIME-type) metadata. There are three cases; first one in this order that
applies is used:
- `metadata_file_type` argument is present in call, use that.
- `file_type` decorator argument is populated, use that.
- `file_type` decorator argument is omitted or None, don't apply `.metadata.filetype`
(assume the partitioner will do that for itself, like `partition_image()`.
- Replace `filename` with `metadata_filename` when present.
- Replace `last_modified` with `metadata_last_modified` when present.
- Apply `url` metadata when present.
"""
def decorator(func: Callable[_P, list[Element]]) -> Callable[_P, list[Element]]:
"""The decorator function itself.
This function is returned by the `apply_metadata()` function and is the actual decorator.
Think of `apply_metadata()` as a factory function that configures this decorator, in
particular by setting its `file_type` value.
"""
@functools.wraps(func)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> list[Element]:
elements = func(*args, **kwargs)
call_args = get_call_args_applying_defaults(func, *args, **kwargs)
# ------------------------------------------------------------------------------------
# unique-ify elements
# ------------------------------------------------------------------------------------
# Do this first to ensure all following operations behave as expected. It's easy for a
# partitioner to re-use an element or metadata instance when its values are common to
# multiple elements. This can lead to very hard-to diagnose bugs downstream when
# mutating one element unexpectedly also mutates others (because they are the same
# instance).
# ------------------------------------------------------------------------------------
elements = _uniqueify_elements_and_metadata(elements)
# ------------------------------------------------------------------------------------
# apply metadata - do this first because it affects the hash computation.
# ------------------------------------------------------------------------------------
# -- `language` - auto-detect language (e.g. eng, spa) --
languages = call_args.get("languages")
detect_language_per_element = call_args.get("detect_language_per_element", False)
language_fallback = call_args.get("language_fallback")
elements = list(
apply_lang_metadata(
elements=elements,
languages=languages,
detect_language_per_element=detect_language_per_element,
language_fallback=language_fallback,
)
)
# == apply filetype, filename, last_modified, and url metadata ===================
metadata_kwargs: dict[str, Any] = {}
# -- `filetype` (MIME-type) metadata --
metadata_file_type = call_args.get("metadata_file_type") or file_type
if metadata_file_type is not None:
metadata_kwargs["filetype"] = metadata_file_type.mime_type
# -- `filename` metadata - override with metadata_filename when it's present --
filename = call_args.get("metadata_filename") or call_args.get("filename")
if filename:
metadata_kwargs["filename"] = filename
# -- `last_modified` metadata - override with metadata_last_modified when present --
metadata_last_modified = call_args.get("metadata_last_modified")
if metadata_last_modified:
metadata_kwargs["last_modified"] = metadata_last_modified
# -- `url` metadata - record url when present --
url = call_args.get("url")
if url:
metadata_kwargs["url"] = url
# -- update element.metadata in single pass --
for element in elements:
# NOTE(robinson) - Attached files have already run through this logic in their own
# partitioning function
if element.metadata.attached_to_filename:
continue
element.metadata.update(ElementMetadata(**metadata_kwargs))
# ------------------------------------------------------------------------------------
# compute hash ids (when so requestsd)
# ------------------------------------------------------------------------------------
# -- Compute and apply hash-ids if the user does not want UUIDs. Note this mutates the
# -- elements themselves, not their metadata.
unique_element_ids: bool = call_args.get("unique_element_ids", False)
if unique_element_ids is False:
elements = _assign_hash_ids(elements)
# ------------------------------------------------------------------------------------
# assign parent-id - do this after hash computation so parent-id is stable.
# ------------------------------------------------------------------------------------
# -- `parent_id` - process category-level etc. to assign parent-id --
elements = set_element_hierarchy(elements)
return elements
return wrapper
return decorator
def _assign_hash_ids(elements: list[Element]) -> list[Element]:
"""Converts `.id` of each element from UUID to hash and remaps `parent_id` accordingly.
The hash is based on the `.text` of the element, but also on its page-number and sequence number
on that page. This provides for deterministic results even when the document is split into one
or more fragments for parallel processing.
After hashing, any `element.metadata.parent_id` that references a known original UUID is
updated to the corresponding new hash ID. Parent IDs that do not appear in the mapping (e.g.
because the parent element was filtered out before hashing, or the ID was set manually to an
external value) are left unchanged.
"""
# -- generate sequence number for each element on a page --
page_seq_counts = {}
id_mapping = {}
for element in elements:
page_number = element.metadata.page_number
seq_on_page_counter = page_seq_counts.get(page_number, 0)
original_id = element.id
element.id_to_hash(seq_on_page_counter)
id_mapping[original_id] = element.id
page_seq_counts[page_number] = seq_on_page_counter + 1
for element in elements:
if element.metadata.parent_id is not None and element.metadata.parent_id in id_mapping:
element.metadata.parent_id = id_mapping[element.metadata.parent_id]
return elements
def _uniqueify_elements_and_metadata(elements: list[Element]) -> list[Element]:
"""Ensure each of `elements` and their metadata are unique instances.
This prevents hard-to-diagnose bugs downstream when mutating one element unexpectedly also
mutates others because they are the same instance.
"""
def iter_unique_elements(elements: list[Element]) -> Iterator[Element]:
"""Substitute deep-copies of any non-unique elements or metadata in `elements`."""
seen_elements: set[int] = set()
seen_metadata: set[int] = set()
for element in elements:
if id(element) in seen_elements:
element = copy.deepcopy(element)
if id(element.metadata) in seen_metadata:
element.metadata = copy.deepcopy(element.metadata)
seen_elements.add(id(element))
seen_metadata.add(id(element.metadata))
yield element
return list(iter_unique_elements(elements))