chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:33:56 +08:00
commit 461bf6fd40
1313 changed files with 1079898 additions and 0 deletions
View File
+150
View File
@@ -0,0 +1,150 @@
from typing import IO, Optional, Tuple, Union
from charset_normalizer import detect
from unstructured.errors import UnprocessableEntityError
from unstructured.partition.common.common import convert_to_bytes
ENCODE_REC_THRESHOLD = 0.8
# popular encodings from https://en.wikipedia.org/wiki/Popularity_of_text_encodings
COMMON_ENCODINGS = [
"utf_8",
"iso_8859_1",
"iso_8859_6",
"iso_8859_8",
"ascii",
"big5",
"utf_16",
"utf_16_be",
"utf_16_le",
"utf_32",
"utf_32_be",
"utf_32_le",
"euc_jis_2004",
"euc_jisx0213",
"euc_jp",
"euc_kr",
"gb18030",
"shift_jis",
"shift_jis_2004",
"shift_jisx0213",
]
def format_encoding_str(encoding: str) -> str:
"""Format input encoding string (e.g., `utf-8`, `iso-8859-1`, etc).
Parameters
----------
encoding
The encoding string to be formatted (e.g., `UTF-8`, `utf_8`, `ISO-8859-1`, `iso_8859_1`,
etc).
"""
formatted_encoding = encoding.lower().replace("_", "-")
# Special case for Arabic and Hebrew charsets with directional annotations
annotated_encodings = ["iso-8859-6-i", "iso-8859-6-e", "iso-8859-8-i", "iso-8859-8-e"]
if formatted_encoding in annotated_encodings:
formatted_encoding = formatted_encoding[:-2] # remove the annotation
return formatted_encoding
def validate_encoding(encoding: str) -> bool:
"""Checks if an encoding string is valid. Helps to avoid errors in cases where
invalid encodings are extracted from malformed documents."""
for common_encoding in COMMON_ENCODINGS:
if format_encoding_str(common_encoding) == format_encoding_str(encoding):
return True
return False
def detect_file_encoding(
filename: str = "",
file: Optional[Union[bytes, IO[bytes]]] = None,
) -> Tuple[str, str]:
if filename:
with open(filename, "rb") as f:
byte_data = f.read()
elif file:
byte_data = convert_to_bytes(file)
else:
raise FileNotFoundError("No filename nor file were specified")
result = detect(byte_data)
encoding = result["encoding"]
confidence = result["confidence"]
if encoding is None or confidence is None or confidence < ENCODE_REC_THRESHOLD:
# Encoding detection failed, fallback to predefined encodings
for enc in COMMON_ENCODINGS:
try:
if filename:
with open(filename, encoding=enc) as f:
file_text = f.read()
else:
file_text = byte_data.decode(enc)
encoding = enc
break
except (UnicodeDecodeError, UnicodeError):
continue
else:
# NOTE: Use UnprocessableEntityError instead of UnicodeDecodeError to avoid
# logging the entire file content. UnicodeDecodeError automatically stores
# the complete input data, which can be problematic for large files.
raise UnprocessableEntityError(
"Unable to determine file encoding after trying all common encodings. "
"File may be corrupted or in an unsupported format."
) from None
else:
# NOTE: Catch UnicodeDecodeError to avoid logging the entire file content.
# UnicodeDecodeError automatically stores the complete input data in its
# 'object' attribute, which can cause issues with large files in logging
# and error reporting systems.
try:
file_text = byte_data.decode(encoding)
except (UnicodeDecodeError, UnicodeError):
raise UnprocessableEntityError(
f"File encoding detection failed: detected '{encoding}' but decode failed. "
f"File may be corrupted or in an unsupported format."
) from None
formatted_encoding = format_encoding_str(encoding)
return formatted_encoding, file_text
def read_txt_file(
filename: str = "",
file: Optional[Union[bytes, IO[bytes]]] = None,
encoding: Optional[str] = None,
) -> Tuple[str, str]:
"""Extracts document metadata from a plain text document."""
if filename:
if encoding:
formatted_encoding = format_encoding_str(encoding)
with open(filename, encoding=formatted_encoding) as f:
try:
file_text = f.read()
except (UnicodeDecodeError, UnicodeError) as error:
raise error
else:
formatted_encoding, file_text = detect_file_encoding(filename)
elif file:
if encoding:
formatted_encoding = format_encoding_str(encoding)
try:
file_content = file if isinstance(file, bytes) else file.read()
if isinstance(file_content, bytes):
file_text = file_content.decode(formatted_encoding)
else:
file_text = file_content
except (UnicodeDecodeError, UnicodeError) as error:
raise error
else:
formatted_encoding, file_text = detect_file_encoding(file=file)
else:
raise FileNotFoundError("No filename was specified")
return formatted_encoding, file_text
@@ -0,0 +1,81 @@
from __future__ import annotations
import os
import re
import tempfile
from typing import IO
from unstructured.errors import UnprocessableEntityError
from unstructured.partition.common.common import exactly_one
from unstructured.utils import requires_dependencies
@requires_dependencies(["pypandoc"])
def convert_file_to_text(filename: str, source_format: str, target_format: str) -> str:
"""Uses pandoc to convert the source document to a raw text string."""
import pypandoc
try:
text: str = pypandoc.convert_file(
filename, target_format, format=source_format, sandbox=True
)
except FileNotFoundError as err:
msg = (
f"Error converting the file to text. Ensure you have the pandoc package installed on"
f" your system. Installation instructions are available at"
f" https://pandoc.org/installing.html. The original exception text was:\n{err}"
)
raise FileNotFoundError(msg)
except RuntimeError as err:
err_str = str(err)
if source_format == "epub" and (
"Couldn't extract ePub file" in err_str
or "No entry on path" in err_str
or re.search(r"exitcode ['\"]?64['\"]?", err_str)
):
raise UnprocessableEntityError(f"Invalid EPUB file: {err_str}")
supported_source_formats, _ = pypandoc.get_pandoc_formats()
if source_format == "rtf" and source_format not in supported_source_formats:
additional_info = (
"Support for RTF files is not available in the current pandoc installation. "
"It was introduced in pandoc 2.14.2.\n"
"Reference: https://pandoc.org/releases.html#pandoc-2.14.2-2021-08-21"
)
else:
additional_info = ""
msg = (
f"{err}\n\n{additional_info}\n\n"
f"Current version of pandoc: {pypandoc.get_pandoc_version()}\n"
"Make sure you have the right version installed in your system. Please follow the"
" pandoc installation instructions in README.md to install the right version."
)
raise RuntimeError(msg)
return text
def convert_file_to_html_text_using_pandoc(
source_format: str, filename: str | None = None, file: IO[bytes] | None = None
) -> str:
"""Converts a document to HTML raw text.
Enables the doucment to be processed using `partition_html()`.
"""
exactly_one(filename=filename, file=file)
if file is not None:
with tempfile.TemporaryDirectory() as temp_dir_path:
tmp_file_path = os.path.join(temp_dir_path, f"tmp_file.{source_format}")
with open(tmp_file_path, "wb") as tmp_file:
tmp_file.write(file.read())
return convert_file_to_text(
filename=tmp_file_path, source_format=source_format, target_format="html"
)
assert filename is not None
return convert_file_to_text(
filename=filename, source_format=source_format, target_format="html"
)
+965
View File
@@ -0,0 +1,965 @@
"""Automatically detect file-type based on inspection of the file's contents.
Auto-detection proceeds via a sequence of strategies. The first strategy to confidently determine a
file-type returns that value. A strategy that is not applicable, either because it lacks the input
required or fails to determine a file-type, returns `None` and execution continues with the next
strategy.
`_FileTypeDetector` is the main object and implements the three strategies.
The three strategies are:
- Use MIME-type asserted by caller in the `content_type` argument.
- Guess a MIME-type using libmagic, falling back to the `filetype` package when libmagic is
unavailable.
- Map filename-extension to a `FileType` member.
A file that fails all three strategies is assigned the value `FileType.UNK`, for "unknown".
`_FileTypeDetectionContext` encapsulates the various arguments received by `detect_filetype()` and
provides values derived from them. This object is immutable and can be passed to delegates of
`_FileTypeDetector` to provide whatever context they need on the current detection instance.
`_FileTypeDetector` delegates to _differentiator_ objects like `_ZipFileDifferentiator` for
specialized discrimination and/or confirmation of ambiguous or frequently mis-identified
MIME-types. Additional differentiators are planned, one for `application/x-ole-storage`
(DOC, PPT, XLS, and MSG file-types) and perhaps others.
"""
from __future__ import annotations
import contextlib
import functools
import importlib.util
import io
import json
import os
import re
import tempfile
import zipfile
from functools import cached_property
from typing import IO, Callable, Iterator, Optional, cast
import filetype as ft
from olefile import OleFileIO
from oxmsg.storage import Storage
from typing_extensions import ParamSpec
from unstructured.documents.elements import Element
from unstructured.file_utils.encoding import detect_file_encoding, format_encoding_str
from unstructured.file_utils.model import FileType
from unstructured.logger import logger
from unstructured.nlp.patterns import EMAIL_HEAD_RE, LIST_OF_DICTS_PATTERN
from unstructured.partition.common.common import add_element_metadata, exactly_one
from unstructured.partition.common.metadata import set_element_hierarchy
from unstructured.utils import get_call_args_applying_defaults
_JSON_DISAMBIGUATION_CHUNK_SIZE = 8192
_JSON_DISAMBIGUATION_MAX_CHARS = 1024 * 1024
try:
importlib.import_module("magic")
LIBMAGIC_AVAILABLE = True
except ImportError:
LIBMAGIC_AVAILABLE = False # pyright: ignore[reportConstantRedefinition]
def detect_filetype(
file_path: str | None = None,
file: IO[bytes] | tempfile.SpooledTemporaryFile | None = None,
encoding: str | None = None,
content_type: str | None = None,
metadata_file_path: Optional[str] = None,
) -> FileType:
"""Determine file-type of specified file using libmagic and/or fallback methods.
One of `file_path` or `file` must be specified. A `file_path` that does not
correspond to a file on the filesystem raises `ValueError`.
Args:
content_type: MIME-type of document-source, when already known. Providing
a value for this argument disables auto-detection unless it does not map
to a FileType member or is ambiguous, in which case it is ignored.
encoding: Only used for textual file-types. When omitted, `utf-8` is
assumed. Should generally be omitted except to resolve a problem with
textual file-types like HTML.
metadata_file_path: Only used when `file` is provided and then only as a
source for a filename-extension that may be needed as a secondary
content-type indicator. Ignored with the document is specified using
`file_path`.
Returns:
A member of the `FileType` enumeration, `FileType.UNK` when the file type
could not be determined or is not supported.
Raises:
ValueError: when:
- `file_path` is specified but does not correspond to a file on the
filesystem.
- Neither `file_path` nor `file` were specified.
"""
file_buffer = file
if isinstance(file, tempfile.SpooledTemporaryFile):
file_buffer = io.BytesIO(file.read())
file.seek(0)
ctx = _FileTypeDetectionContext.new(
file_path=file_path,
file=file_buffer,
encoding=encoding,
content_type=content_type,
metadata_file_path=metadata_file_path,
)
return _FileTypeDetector.file_type(ctx)
def is_json_processable(
filename: Optional[str] = None,
file: Optional[IO[bytes]] = None,
file_text: Optional[str] = None,
encoding: Optional[str] = "utf-8",
) -> bool:
"""True when file looks like a JSON array of objects.
Uses regex on a file prefix, so not entirely reliable but good enough if you already know the
file is JSON.
"""
exactly_one(filename=filename, file=file, file_text=file_text)
if file_text is None:
file_text = _FileTypeDetectionContext.new(
file_path=filename, file=file, encoding=encoding
).text_head
return re.match(LIST_OF_DICTS_PATTERN, file_text) is not None
def is_ndjson_processable(
filename: Optional[str] = None,
file: Optional[IO[bytes]] = None,
file_text: Optional[str] = None,
encoding: Optional[str] = "utf-8",
allow_truncated_single_line: bool = False,
) -> bool:
"""True when file looks like newline-delimited JSON objects.
NDJSON is a sequence of one JSON value per line, conventionally an object on each line. A
payload that parses as a single JSON value (e.g. a multi-line `{...}` object or a `[...]`
array) is *not* NDJSON and must not be matched here, otherwise `partition_ndjson` will fail
later when it splits the text by lines and tries to parse each fragment.
"""
exactly_one(filename=filename, file=file, file_text=file_text)
allow_truncated = allow_truncated_single_line
if file_text is None:
file_text, allow_truncated = _FileTypeDetectionContext.new(
file_path=filename, file=file, encoding=encoding
).json_disambiguation_text
text = file_text.lstrip()
if not text or not text.startswith("{"):
return False
newline_idx = text.find("\n")
if newline_idx == -1:
# Single-line input. A complete `{...}` parses as a dict and is treated as 1-record
# NDJSON (existing tests and `partition_ndjson` rely on this). When the caller knows this
# is a truncated first line from a JSON-like payload, a parse failure is still compatible
# with a long 1-record NDJSON payload.
try:
return isinstance(json.loads(text), dict)
except json.JSONDecodeError:
return allow_truncated
# Multi-line input. NDJSON requires each record to be on its own line, so the first line
# must independently parse as a JSON object. A pretty-printed single JSON object has its
# first line be just `{` (or similar fragment) which won't parse alone — that's how we
# distinguish it from real NDJSON.
first_line = text[:newline_idx].rstrip()
if not first_line:
return False
try:
return isinstance(json.loads(first_line), dict)
except json.JSONDecodeError:
return False
class _FileTypeDetector:
"""Determines file type from a variety of possible inputs."""
def __init__(self, ctx: _FileTypeDetectionContext):
self._ctx = ctx
@classmethod
def file_type(cls, ctx: _FileTypeDetectionContext) -> FileType:
"""Detect file-type of document-source described by `ctx`."""
return cls(ctx)._file_type
@property
def _file_type(self) -> FileType:
"""FileType member corresponding to this document source."""
# -- An explicit content-type most commonly asserted by the client/SDK and is therefore
# -- inherently unreliable. On the other hand, binary file-types can be detected with 100%
# -- accuracy. So start with binary types and only then consider an asserted content-type,
# -- generally as a last resort.
if (
( # strategy 1: most binary types can be detected with 100% accuracy
predicted_file_type := self._known_binary_file_type
)
or ( # strategy 2: use content-type asserted by caller
predicted_file_type := self._file_type_from_content_type
)
or ( # strategy 3: guess MIME-type using libmagic and use that
predicted_file_type := self._file_type_from_guessed_mime_type
)
or ( # strategy 4: use filename-extension, like ".docx" -> FileType.DOCX
predicted_file_type := self._file_type_from_file_extension
)
):
result_file_type = predicted_file_type
else:
# give up and report FileType.UNK
result_file_type = FileType.UNK
if result_file_type == FileType.JSON:
# edge case where JSON/NDJSON content without file extension
# (magic lib can't distinguish them)
result_file_type = self._disambiguate_json_file_type
return result_file_type
@property
def _known_binary_file_type(self) -> FileType | None:
"""Detect file-type for binary types we can positively detect."""
if file_type := _OleFileDetector.file_type(self._ctx):
return file_type
self._ctx.rule_out_cfb_content_types()
if file_type := _ZipFileDetector.file_type(self._ctx):
return file_type
self._ctx.rule_out_zip_content_types()
return None
@property
def _file_type_from_content_type(self) -> FileType | None:
"""Map passed content-type argument to a file-type, subject to certain rules."""
# -- when no content-type was asserted by caller, this strategy is not applicable --
if not self._ctx.content_type:
return None
# -- otherwise we trust the passed `content_type` as long as `FileType` recognizes it --
return FileType.from_mime_type(self._ctx.content_type)
@property
def _disambiguate_json_file_type(self) -> FileType:
"""Disambiguate JSON/NDJSON file-type based on file contents.
NDJSON is detected first because it has the strictest signature (multiple JSON values
separated by newlines, with the first line independently parsable). Anything else that
libmagic flagged as JSON is classified as `FileType.JSON`; the JSON partitioner has its
own `is_json_processable` schema check and will reject non-conforming payloads with a
clear error.
"""
file_text, allow_truncated_single_line = self._ctx.json_disambiguation_text
if is_ndjson_processable(
file_text=file_text,
allow_truncated_single_line=allow_truncated_single_line,
):
return FileType.NDJSON
return FileType.JSON
@property
def _file_type_from_guessed_mime_type(self) -> FileType | None:
"""FileType based on auto-detection of MIME-type by libmagic.
In some cases refinements are necessary on the magic-derived MIME-types. This process
includes applying those rules, most of which are accumulated through practical experience.
"""
mime_type = self._ctx.mime_type
extension = self._ctx.extension
# -- when libmagic is not installed, the `filetype` package is used instead.
# -- `filetype.guess()` returns `None` for file-types it does not support, which
# -- unfortunately includes all the textual file-types like CSV, EML, HTML, MD, RST, RTF,
# -- TSV, and TXT. When we have no guessed MIME-type, this strategy is not applicable.
if mime_type is None:
return None
if mime_type.endswith("xml"):
return FileType.HTML if extension in (".html", ".htm") else FileType.XML
if differentiator := _TextFileDifferentiator.applies(self._ctx):
return differentiator.file_type
# -- All source-code files (e.g. *.py, *.js) are classified as plain text for the moment --
if self._ctx.has_code_mime_type:
return FileType.TXT
if mime_type.endswith("empty"):
return FileType.EMPTY
if mime_type.endswith("json") and self._ctx.extension == ".ndjson":
return FileType.NDJSON
# -- if no more-specific rules apply, use the MIME-type -> FileType mapping when present --
file_type = FileType.from_mime_type(mime_type)
if file_type != FileType.UNK:
return file_type
# -- on some environments libmagic can return a generic/unhelpful MIME-type
# -- like octet-stream") for files that the `filetype` package identify.
# -- when that happens we retry using `filetype` `FileType.UNK` results.
if LIBMAGIC_AVAILABLE:
fallback_mime_type = (
ft.guess_mime(self._ctx.file_path)
if self._ctx.file_path
else ft.guess_mime(self._ctx.file_head)
)
fallback_file_type = FileType.from_mime_type(fallback_mime_type)
if fallback_file_type and fallback_file_type != FileType.UNK:
return fallback_file_type
return None
@cached_property
def _file_type_from_file_extension(self) -> FileType | None:
"""Determine file-type from filename extension.
Returns `None` when no filename is available or when the extension does not map to a
supported file-type.
"""
return FileType.from_extension(self._ctx.extension)
class _FileTypeDetectionContext:
"""Provides all arguments to auto-file detection and values derived from them.
NOTE that `._content_type` is mutable via `.rule_out_*_content_types()` methods, so it should
not be assumed to be a constant value across those calls.
This keeps computation of derived values out of the file-detection code but more importantly
allows the main filetype-detector to pass the full context to any delegates without coupling
itself to which values it might need.
"""
def __init__(
self,
file_path: str | None = None,
*,
file: IO[bytes] | None = None,
encoding: str | None = None,
content_type: str | None = None,
metadata_file_path: str | None = None,
):
self._file_path_arg = file_path
self._file_arg = file
self._encoding_arg = encoding
self._content_type = content_type
self._metadata_file_path = metadata_file_path
@classmethod
def new(
cls,
*,
file_path: str | None,
file: IO[bytes] | None,
encoding: str | None,
content_type: str | None = None,
metadata_file_path: str | None = None,
) -> _FileTypeDetectionContext:
self = cls(
file_path=file_path,
file=file,
encoding=encoding,
content_type=content_type,
metadata_file_path=metadata_file_path,
)
self._validate()
return self
@property
def content_type(self) -> str | None:
"""MIME-type asserted by caller; not based on inspection of file by this process.
Would commonly occur when the file was downloaded via HTTP and a `"Content-Type:` header was
present on the response. These are often ambiguous and sometimes just wrong so get some
further verification. All lower-case when not `None`.
"""
# -- Note `._content_type` is mutable via `.invalidate_content_type()` so this cannot be a
# -- `@cached_property`.
return self._content_type.lower() if self._content_type else None
@cached_property
def encoding(self) -> str:
"""Character-set used to encode text of this file.
Relevant for textual file-types only, like HTML, TXT, JSON, etc.
"""
return format_encoding_str(self._encoding_arg or "utf-8")
@cached_property
def extension(self) -> str:
"""Best filename-extension we can muster, "" when there is no available source."""
# -- get from file_path, or file when it has a name (path) --
with self.open() as file:
if hasattr(file, "name") and file.name:
return os.path.splitext(file.name)[1].lower()
# -- otherwise use metadata file-path when provided --
if file_path := self._metadata_file_path:
return os.path.splitext(file_path)[1].lower()
# -- otherwise empty str means no extension, same as a path like "a/b/name-no-ext" --
return ""
@cached_property
def file_head(self) -> bytes:
"""The initial bytes of the file to be recognized, for use with libmagic detection."""
with self.open() as file:
return file.read(8192)
@cached_property
def file_path(self) -> str | None:
"""Filesystem path to file to be inspected, when provided on call.
None when the caller specified the source as a file-like object instead. Useful for user
feedback on an error, but users of context should have little use for it otherwise.
"""
if (file_path := self._file_path_arg) is None:
return None
return os.path.realpath(file_path) if os.path.islink(file_path) else file_path
@cached_property
def has_code_mime_type(self) -> bool:
"""True when `mime_type` plausibly indicates a programming language source-code file."""
mime_type = self.mime_type
if mime_type is None:
return False
# -- check Go separately to avoid matching other MIME type containing "go" --
if mime_type == "text/x-go":
return True
return any(
lang in mime_type
for lang in [
"c#",
"c++",
"cpp",
"csharp",
"java",
"javascript",
"php",
"python",
"ruby",
"swift",
"typescript",
]
)
@cached_property
def is_zipfile(self) -> bool:
"""True when file is a Zip archive."""
with self.open() as file:
return zipfile.is_zipfile(file)
@cached_property
def mime_type(self) -> str | None:
"""The best MIME-type we can get from `magic` (or `filetype` package).
A `str` return value is always in lower-case.
"""
file_path = self.file_path
if LIBMAGIC_AVAILABLE:
import magic
magic_mime = (
magic.from_file(file_path, mime=True)
if file_path
else magic.from_buffer(self.file_head, mime=True)
)
magic_mime = magic_mime.lower() if magic_mime else None
# When libmagic returns None or "application/octet-stream", try the filetype package
# (magic-byte signatures) for formats libmagic often mis-detects (e.g. BMP, HEIC, WAV).
if magic_mime and magic_mime != "application/octet-stream":
return magic_mime
ft_mime = ft.guess_mime(file_path) if file_path else ft.guess_mime(self.file_head)
if ft_mime:
return ft_mime.lower()
# filetype could not identify; same outcome as if we had not tried it.
return magic_mime
mime_type = ft.guess_mime(file_path) if file_path else ft.guess_mime(self.file_head)
if mime_type is None:
logger.warning(
"libmagic is unavailable but assists in filetype detection. Please consider"
" installing libmagic for better results."
)
return None
return mime_type.lower()
@contextlib.contextmanager
def open(self) -> Iterator[IO[bytes]]:
"""Encapsulates complexity of dealing with file-path or file-like-object.
Provides an `IO[bytes]` object as the "common-denominator" document source.
Must be used as a context manager using a `with` statement:
with self._file as file:
do things with file
File is guaranteed to be at read position 0 when called.
"""
if self.file_path:
with open(self.file_path, "rb") as f:
yield f
else:
file = self._file_arg
assert file is not None # -- guaranteed by `._validate()` --
file.seek(0)
yield file
def rule_out_cfb_content_types(self) -> None:
"""Invalidate content-type when a legacy MS-Office file-type is asserted.
Used before returning `None`; at that point we know the file is not one of these formats
so if the asserted `content-type` is a legacy MS-Office type we know it's wrong and should
not be used as a fallback later in the detection process.
"""
if FileType.from_mime_type(self._content_type) in (
FileType.DOC,
FileType.MSG,
FileType.PPT,
FileType.XLS,
):
self._content_type = None
def rule_out_zip_content_types(self) -> None:
"""Invalidate content-type when an MS-Office 2007+ file-type is asserted.
Used before returning `None`; at that point we know the file is not one of these formats
so if the asserted `content-type` is an MS-Office 2007+ type we know it's wrong and should
not be used as a fallback later in the detection process.
"""
if FileType.from_mime_type(self._content_type) in (
FileType.DOCX,
FileType.EPUB,
FileType.ODT,
FileType.PPTX,
FileType.XLSX,
FileType.ZIP,
):
self._content_type = None
@cached_property
def text_head(self) -> str:
"""The initial characters of the text file for use with text-format differentiation.
Raises:
UnicodeDecodeError if file cannot be read as text.
"""
# TODO: only attempts fallback character-set detection for file-path case, not for
# file-like object case. Seems like we should do both.
if file := self._file_arg:
file.seek(0)
content = file.read(4096)
file.seek(0)
return (
content
if isinstance(content, str)
else content.decode(encoding=self.encoding, errors="ignore")
)
file_path = self.file_path
assert file_path is not None # -- guaranteed by `._validate` --
try:
with open(file_path, encoding=self.encoding) as f:
return f.read(4096)
except UnicodeDecodeError:
encoding, _ = detect_file_encoding(filename=file_path)
with open(file_path, encoding=encoding) as f:
return f.read(4096)
@cached_property
def json_disambiguation_text(self) -> tuple[str, bool]:
"""Text prefix for JSON/NDJSON disambiguation and whether the first line was truncated."""
if file := self._file_arg:
file.seek(0)
content, first_line_truncated = self._read_until_newline_or_limit(file)
file.seek(0)
if isinstance(content, str):
return content, first_line_truncated
return content.decode(encoding=self.encoding, errors="ignore"), first_line_truncated
file_path = self.file_path
assert file_path is not None # -- guaranteed by `._validate` --
try:
with open(file_path, encoding=self.encoding) as f:
content, first_line_truncated = self._read_until_newline_or_limit(f)
assert isinstance(content, str)
return content, first_line_truncated
except UnicodeDecodeError:
encoding, _ = detect_file_encoding(filename=file_path)
with open(file_path, encoding=encoding) as f:
content, first_line_truncated = self._read_until_newline_or_limit(f)
assert isinstance(content, str)
return content, first_line_truncated
def _validate(self) -> None:
"""Raise if the context is invalid."""
if self.file_path and not os.path.isfile(self.file_path):
raise FileNotFoundError(f"no such file {self._file_path_arg}")
if not self.file_path and not self._file_arg:
raise ValueError("either `file_path` or `file` argument must be provided")
@staticmethod
def _read_until_newline_or_limit(file: IO) -> tuple[str | bytes, bool]:
"""Read through the first newline, stopping at a bounded prefix if none is found."""
chunks: list[str | bytes] = []
chars_read = 0
while chars_read < _JSON_DISAMBIGUATION_MAX_CHARS:
chars_to_read = min(
_JSON_DISAMBIGUATION_CHUNK_SIZE,
_JSON_DISAMBIGUATION_MAX_CHARS - chars_read,
)
chunk = file.read(chars_to_read)
if not chunk:
return _FileTypeDetectionContext._join_text_chunks(chunks), False
newline = b"\n" if isinstance(chunk, bytes) else "\n"
newline_idx = chunk.find(newline)
if newline_idx != -1:
chunks.append(chunk[: newline_idx + 1])
return _FileTypeDetectionContext._join_text_chunks(chunks), False
chunks.append(chunk)
chars_read += len(chunk)
return _FileTypeDetectionContext._join_text_chunks(chunks), True
@staticmethod
def _join_text_chunks(chunks: list[str | bytes]) -> str | bytes:
"""Join chunks without mixing text and bytes types."""
if chunks and isinstance(chunks[0], bytes):
return b"".join(cast(list[bytes], chunks))
return "".join(cast(list[str], chunks))
class _OleFileDetector:
"""Detect and differentiate a CFB file, aka. "OLE" file.
Compound File Binary Format (CFB), aka. OLE file, is use by Microsoft for legacy MS Office
files (DOC, PPT, XLS) as well as for Outlook MSG files.
"""
def __init__(self, ctx: _FileTypeDetectionContext):
self._ctx = ctx
@classmethod
def file_type(cls, ctx: _FileTypeDetectionContext) -> FileType | None:
"""Specific file-type when file is a CFB file, `None` otherwise."""
return cls(ctx)._file_type
@property
def _file_type(self) -> FileType | None:
"""Differentiated file-type for Microsoft Compound File Binary Format (CFBF).
Returns one of:
- `FileType.DOC`
- `FileType.PPT`
- `FileType.XLS`
- `FileType.MSG`
- `None` when the file is not one of these.
"""
# -- all CFB files share common magic number, start with that --
if not self._is_ole_file:
return None
# -- check storage contents of the ole file for file-type specific stream names --
if (ole_file_type := self._ole_file_type) is not None:
return ole_file_type
return None
@cached_property
def _is_ole_file(self) -> bool:
"""True when file has CFB magic first 8 bytes."""
with self._ctx.open() as file:
return file.read(8) == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
@cached_property
def _ole_file_type(self) -> FileType | None:
with self._ctx.open() as f:
ole = OleFileIO(f) # pyright: ignore[reportUnknownVariableType]
root_storage = Storage.from_ole(ole) # pyright: ignore[reportUnknownMemberType]
for stream in root_storage.streams:
if stream.name == "WordDocument":
return FileType.DOC
elif stream.name == "PowerPoint Document":
return FileType.PPT
elif stream.name == "Workbook":
return FileType.XLS
elif stream.name == "__properties_version1.0":
return FileType.MSG
return None
class _TextFileDifferentiator:
"""Refine a textual file-type that may not be as specific as it could be."""
def __init__(self, ctx: _FileTypeDetectionContext):
self._ctx = ctx
@classmethod
def applies(cls, ctx: _FileTypeDetectionContext) -> _TextFileDifferentiator | None:
"""Constructs an instance, but only if this differentiator applies in `ctx`."""
mime_type = ctx.mime_type
return (
cls(ctx)
if mime_type and (mime_type == "message/rfc822" or mime_type.startswith("text"))
else None
)
@cached_property
def file_type(self) -> FileType:
"""Differentiated file-type for textual content.
Always produces a file-type, worst case that's `FileType.TXT` when nothing more specific
applies.
"""
extension = self._ctx.extension
if extension in [
".csv",
".eml",
".html",
".json",
".markdown",
".md",
".org",
".p7s",
".rst",
".rtf",
".tab",
".tsv",
]:
return FileType.from_extension(extension) or FileType.TXT
# NOTE(crag): for older versions of the OS libmagic package, such as is currently
# installed on the Unstructured docker image, .json files resolve to "text/plain"
# rather than "application/json". this corrects for that case.
if self._is_json:
return FileType.JSON
if self._is_csv:
return FileType.CSV
if self._is_eml:
return FileType.EML
if extension in (".text", ".txt"):
return FileType.TXT
# Safety catch
if file_type := FileType.from_mime_type(self._ctx.mime_type):
return file_type
return FileType.TXT
@cached_property
def _is_csv(self) -> bool:
"""True when file is plausibly in Comma Separated Values (CSV) format."""
def count_commas(text: str):
"""Counts the number of commas in a line, excluding commas in quotes."""
pattern = r"(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$),"
matches = re.findall(pattern, text)
return len(matches)
lines = self._ctx.text_head.strip().splitlines()
if len(lines) < 2:
return False
# -- check at most the first 10 lines --
lines = lines[: len(lines)] if len(lines) < 10 else lines[:10]
# -- any lines without at least one comma disqualifies the file --
if any("," not in line for line in lines):
return False
header_count = count_commas(lines[0])
return all(count_commas(line) == header_count for line in lines[1:])
@cached_property
def _is_eml(self) -> bool:
"""Checks if a text/plain file is actually a .eml file.
Uses a regex pattern to see if the start of the file matches the typical pattern for a .eml
file.
"""
return EMAIL_HEAD_RE.match(self._ctx.text_head) is not None
@cached_property
def _is_json(self) -> bool:
"""True when file is JSON collection.
A JSON file that contains only a string, number, or boolean, while valid JSON, will fail
this test since it is not partitionable.
"""
text_head = self._ctx.text_head
# -- an empty file is not JSON --
if not text_head.lstrip():
return False
# -- has to be a list or object, no string, number, or bool --
if text_head.lstrip()[0] not in "[{":
return False
try:
with self._ctx.open() as file:
json.load(file)
return True
except json.JSONDecodeError:
return False
class _ZipFileDetector:
"""Detect and differentiate a Zip-archive file."""
def __init__(self, ctx: _FileTypeDetectionContext):
self._ctx = ctx
@classmethod
def file_type(cls, ctx: _FileTypeDetectionContext) -> FileType | None:
"""Most specific file-type available when file is a Zip file, `None` otherwise.
MS-Office 2007+ files are detected with 100% accuracy. Otherwise this returns `None`, even
when we can tell it's a Zip file, so later strategies can have a crack at it. In
particular, ODT and EPUB files are Zip archives but are not detected here.
"""
return cls(ctx)._file_type
@cached_property
def _file_type(self) -> FileType | None:
"""Differentiated file-type for a Zip archive.
Returns `FileType.DOCX`, `FileType.PPTX`, or `FileType.XLSX` when one of those applies,
`None` otherwise.
"""
if not self._ctx.is_zipfile:
return None
with self._ctx.open() as file:
zip = zipfile.ZipFile(file)
filenames = zip.namelist()
if any(re.match(r"word/document.*\.xml$", filename) for filename in filenames):
return FileType.DOCX
if any(re.match(r"xl/workbook.*\.xml$", filename) for filename in filenames):
return FileType.XLSX
if any(re.match(r"ppt/presentation.*\.xml$", filename) for filename in filenames):
return FileType.PPTX
# -- ODT and EPUB files place their MIME-type in `mimetype` in the archive root --
if "mimetype" in filenames:
with zip.open("mimetype") as f:
mime_type = f.read().decode("utf-8").strip()
return FileType.from_mime_type(mime_type)
return FileType.ZIP
_P = ParamSpec("_P")
def add_metadata(func: Callable[_P, list[Element]]) -> Callable[_P, list[Element]]:
@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)
if call_args.get("metadata_filename"):
call_args["filename"] = call_args.get("metadata_filename")
metadata_kwargs = {
kwarg: call_args.get(kwarg) for kwarg in ("filename", "url", "text_as_html")
}
# NOTE (yao): do not use cast here as cast(None) still is None
if not str(kwargs.get("model_name", "")).startswith("chipper"):
# NOTE(alan): Skip hierarchy if using chipper, as it should take care of that
elements = set_element_hierarchy(elements)
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 is None:
add_element_metadata(element, **metadata_kwargs)
return elements
return wrapper
def add_filetype(
filetype: FileType,
) -> Callable[[Callable[_P, list[Element]]], Callable[_P, list[Element]]]:
"""Post-process element-metadata for list[Element] from partitioning.
This decorator adds a post-processing step to a document partitioner.
- Adds `.metadata.filetype` (source-document MIME-type) metadata value
This "partial" decorator is present because `partition_image()` does not apply
`.metadata.filetype` this way since each image type has its own MIME-type (e.g. `image.jpeg`,
`image/png`, etc.).
"""
def decorator(func: Callable[_P, list[Element]]) -> Callable[_P, list[Element]]:
@functools.wraps(func)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> list[Element]:
elements = func(*args, **kwargs)
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 is None:
add_element_metadata(element, filetype=filetype.mime_type)
return elements
return wrapper
return decorator
def add_metadata_with_filetype(
filetype: FileType,
) -> Callable[[Callable[_P, list[Element]]], Callable[_P, list[Element]]]:
"""..."""
def decorator(func: Callable[_P, list[Element]]) -> Callable[_P, list[Element]]:
return add_filetype(filetype=filetype)(add_metadata(func))
return decorator
@@ -0,0 +1,9 @@
GOOGLE_DRIVE_EXPORT_TYPES = {
"application/vnd.google-apps.document": "application/"
"vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.google-apps.spreadsheet": "application/"
"vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.google-apps.presentation": "application/"
"vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.google-apps.photo": "image/jpeg",
}
+591
View File
@@ -0,0 +1,591 @@
"""Domain-model for file-types."""
from __future__ import annotations
import enum
from typing import TYPE_CHECKING, Callable, Iterable, Type, cast
from typing_extensions import ParamSpec
if TYPE_CHECKING:
from unstructured.documents.elements import Element
else:
Element = None
def _create_file_type_enum(
cls: Type["FileType"],
value: str,
partitioner_shortname: str | None,
importable_package_dependencies: Iterable[str],
extra_name: str | None,
extensions: Iterable[str],
canonical_mime_type: str,
alias_mime_types: Iterable[str],
partitioner_full_module_path: str | None = None,
) -> "FileType":
"""
Moving here instead of directly in the FileType.__new__ allows us
to dynamically create new enum properties.
FileType.__new__ does not work with dynamic properties.
"""
val = object.__new__(cls)
val._value_ = value
val._partitioner_shortname = partitioner_shortname
val._importable_package_dependencies = tuple(importable_package_dependencies)
val._extra_name = extra_name
val._extensions = tuple(extensions)
val._canonical_mime_type = canonical_mime_type
val._alias_mime_types = tuple(alias_mime_types)
val._partitioner_full_module_path = partitioner_full_module_path
return val
class FileType(enum.Enum):
"""The collection of file-types recognized by `unstructured`.
Note not all of these can be partitioned, e.g. ZIP has no partitioner.
"""
_partitioner_shortname: str | None
"""Like "docx", from which partitioner module and function-name can be derived via template."""
_importable_package_dependencies: tuple[str, ...]
"""Packages that must be available for import for this file-type's partitioner to work."""
_extra_name: str | None
"""`pip install` extra that provides package dependencies for this file-type."""
_extensions: tuple[str, ...]
"""Filename-extensions recognized as this file-type. Use for secondary identification only."""
_canonical_mime_type: str
"""The MIME-type used as `.metadata.filetype` for this file-type."""
_alias_mime_types: tuple[str, ...]
"""MIME-types accepted as identifying this file-type."""
_partitioner_full_module_path: str | None
"""Fully-qualified name of module providing partitioner for this file-type."""
def __new__(
cls,
value: str,
partitioner_shortname: str | None,
importable_package_dependencies: Iterable[str],
extra_name: str | None,
extensions: Iterable[str],
canonical_mime_type: str,
alias_mime_types: Iterable[str],
partitioner_full_module_path: str | None = None,
):
return _create_file_type_enum(
cls,
value,
partitioner_shortname,
importable_package_dependencies,
extra_name,
extensions,
canonical_mime_type,
alias_mime_types,
partitioner_full_module_path,
)
def __lt__(self, other: FileType) -> bool:
"""Makes `FileType` members comparable with relational operators, at least with `<`.
This makes them sortable, in particular it supports sorting for pandas groupby functions.
"""
return self.name < other.name
@classmethod
def from_extension(cls, extension: str | None) -> FileType | None:
"""Select a FileType member based on an extension.
`extension` must include the leading period, like `".pdf"`. Extension is suitable as a
secondary file-type identification method but is unreliable for primary identification.
Returns `None` when `extension` is not registered for any supported file-type.
"""
if extension in (None, "", "."):
return None
# -- not super efficient but plenty fast enough for once-or-twice-per-file use and avoids
# -- limitations on defining a class variable on an Enum.
for m in cls.__members__.values():
if extension in m._extensions:
return m
return None
@classmethod
def from_mime_type(cls, mime_type: str | None) -> FileType | None:
"""Select a FileType member based on a MIME-type.
Returns `None` when `mime_type` is `None` or does not map to the canonical MIME-type of a
`FileType` member or one of its alias MIME-types.
"""
if mime_type is None:
return None
# -- not super efficient but plenty fast enough for once-or-twice-per-file use and avoids
# -- limitations on defining a class variable on an Enum.
for m in cls.__members__.values():
if mime_type == m._canonical_mime_type or mime_type in m._alias_mime_types:
return m
return None
@property
def extra_name(self) -> str | None:
"""The `pip` "extra" that must be installed to provide this file-type's dependencies.
Like "image" for PNG, as in `pip install "unstructured[image]"`.
`None` when partitioning this file-type requires only the base `unstructured` install.
"""
return self._extra_name
@property
def importable_package_dependencies(self) -> tuple[str, ...]:
"""Packages that must be importable for this file-type's partitioner to work.
In general, these are the packages provided by the `pip install` "extra" for this file-type,
like `pip install "unstructured[docx]"` loads the `python-docx` package.
Note that these names are the ones used in an `import` statement, which is not necessarily
the same as the _distribution_ package name used by `pip`. For example, the DOCX
distribution package name is `"python-docx"` whereas the _importable_ package name is
`"docx"`. This latter name as it appears like `import docx` is what is provided by this
property.
The return value is an empty tuple for file-types that do not require optional dependencies.
Note this property does not complain when accessed on a non-partitionable file-type, it
simply returns an empty tuple because file-types that are not partitionable require no
optional dependencies.
"""
return self._importable_package_dependencies
@property
def is_partitionable(self) -> bool:
"""True when there is a partitioner for this file-type.
Note this does not check whether the dependencies for this file-type are installed so
attempting to partition a file of this type may still fail. This is meant for
distinguishing file-types like ZIP, EMPTY, and UNK which are legitimate file-types
but have no associated partitioner.
"""
return bool(self._partitioner_shortname) or bool(self._partitioner_full_module_path)
@property
def mime_type(self) -> str:
"""The canonical MIME-type for this file-type, suitable for use in metadata.
This value is used in `.metadata.filetype` for elements partitioned from files of this
type. In general it is the "offical", "recommended", or "defacto-standard" MIME-type for
files of this type, in that order, as available.
"""
return self._canonical_mime_type
@property
def partitioner_function_name(self) -> str:
"""Name of partitioner function for this file-type. Like "partition_docx".
Raises when this property is accessed on a file-type that is not partitionable. Use
`.is_partitionable` to avoid exceptions when partitionability is unknown.
"""
# -- Raise when this property is accessed on a FileType member that has no partitioner
# -- shortname. This prevents a harder-to-find bug from appearing far away from this call
# -- when code would try to `getattr(module, None)` or whatever.
if full_module_path := self._partitioner_full_module_path:
return full_module_path.split(".")[-1]
if (shortname := self._partitioner_shortname) is None:
raise ValueError(
f"`.partitioner_function_name` is undefined because FileType.{self.name} is not"
f" partitionable. Use `.is_partitionable` to determine whether a `FileType`"
f" is partitionable."
)
return f"partition_{shortname}"
@property
def partitioner_module_qname(self) -> str:
"""Fully-qualified name of module providing partitioner for this file-type.
e.g. "unstructured.partition.docx" for FileType.DOCX.
"""
# -- Raise when this property is accessed on a FileType member that has no partitioner
# -- shortname. This prevents a harder-to-find bug from appearing far away from this call
# -- when code would try to `importlib.import_module(None)` or whatever.
if full_module_path := self._partitioner_full_module_path:
return ".".join(full_module_path.split(".")[:-1])
if (shortname := self._partitioner_shortname) is None:
raise ValueError(
f"`.partitioner_module_qname` is undefined because FileType.{self.name} is not"
f" partitionable. Use `.is_partitionable` to determine whether a `FileType`"
f" is partitionable."
)
return f"unstructured.partition.{shortname}"
@property
def partitioner_shortname(self) -> str | None:
"""Familiar name of partitioner, like "image" for file-types that use `partition_image()`.
One use is to determine whether a file-type is one of the five image types, all of which
are processed by `partition_image()`.
`None` for file-types that are not partitionable, although `.is_partitionable` is the
preferred way of discovering that.
"""
return self._partitioner_shortname
BMP = (
"bmp", # -- value for this Enum member, like BMP = "bmp" in a simple enum --
"image", # -- partitioner_shortname --
["unstructured_inference"], # -- importable_package_dependencies --
"image", # -- extra_name - like `pip install "unstructured[image]"` in this case --
[".bmp"], # -- extensions - filename extensions that map to this file-type --
"image/bmp", # -- canonical_mime_type - MIME-type written to `.metadata.filetype` --
[
"image/x-bmp",
"image/x-ms-bmp", # returned by older libmagic versions
],
)
CSV = (
"csv",
"csv",
["pandas"],
"csv",
[".csv"],
"text/csv",
[
"application/csv",
"application/x-csv",
"text/comma-separated-values",
"text/x-comma-separated-values",
"text/x-csv",
],
)
DOC = ("doc", "doc", ["docx"], "doc", [".doc"], "application/msword", cast(list[str], []))
DOCX = (
"docx",
"docx",
["docx"],
"docx",
[".docx"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
cast(list[str], []),
)
EML = (
"eml",
"email",
cast(list[str], []),
None,
[".eml", ".p7s"],
"message/rfc822",
cast(list[str], []),
)
EPUB = (
"epub",
"epub",
["pypandoc"],
"epub",
[".epub"],
"application/epub",
["application/epub+zip"],
)
FLAC = (
"flac",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".flac"],
"audio/flac",
["audio/x-flac"],
)
HEIC = (
"heic",
"image",
["unstructured_inference"],
"image",
[".heic"],
"image/heic",
["image/x-heic"],
)
HTML = (
"html",
"html",
cast(list[str], []),
None,
[".html", ".htm"],
"text/html",
cast(list[str], []),
)
JPG = (
"jpg",
"image",
["unstructured_inference"],
"image",
[".jpeg", ".jpg"],
"image/jpeg",
cast(list[str], []),
)
JSON = (
"json",
"json",
cast(list[str], []),
None,
[".json"],
"application/json",
cast(list[str], []),
)
M4A = (
"m4a",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".m4a"],
"audio/mp4",
["audio/x-m4a"],
)
MD = ("md", "md", ["markdown"], "md", [".md"], "text/markdown", ["text/x-markdown"])
MP3 = (
"mp3",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".mp3"],
"audio/mpeg",
["audio/mp3", "audio/x-mp3", "audio/x-mpeg"],
)
MSG = (
"msg",
"msg",
["oxmsg"],
"msg",
[".msg"],
"application/vnd.ms-outlook",
cast(list[str], []),
)
NDJSON = (
"ndjson",
"ndjson",
cast(list[str], []),
None,
[".ndjson"],
"application/x-ndjson",
cast(list[str], []),
)
ODT = (
"odt",
"odt",
["docx", "pypandoc"],
"odt",
[".odt"],
"application/vnd.oasis.opendocument.text",
cast(list[str], []),
)
OGG = (
"ogg",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".ogg", ".oga"],
"audio/ogg",
["audio/x-ogg"],
)
OPUS = (
"opus",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".opus"],
"audio/opus",
cast(list[str], []),
)
ORG = ("org", "org", ["pypandoc"], "org", [".org"], "text/org", cast(list[str], []))
PDF = (
"pdf",
"pdf",
["pdf2image", "pdfminer", "PIL"],
"pdf",
[".pdf"],
"application/pdf",
cast(list[str], []),
)
PNG = (
"png",
"image",
["unstructured_inference"],
"image",
[".png"],
"image/png",
cast(list[str], []),
)
PPT = (
"ppt",
"ppt",
["pptx"],
"ppt",
[".ppt"],
"application/vnd.ms-powerpoint",
cast(list[str], []),
)
PPTX = (
"pptx",
"pptx",
["pptx"],
"pptx",
[".pptx"],
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
cast(list[str], []),
)
RST = ("rst", "rst", ["pypandoc"], "rst", [".rst"], "text/x-rst", cast(list[str], []))
RTF = ("rtf", "rtf", ["pypandoc"], "rtf", [".rtf"], "text/rtf", ["application/rtf"])
TIFF = (
"tiff",
"image",
["unstructured_inference"],
"image",
[".tiff"],
"image/tiff",
cast(list[str], []),
)
TSV = ("tsv", "tsv", ["pandas"], "tsv", [".tab", ".tsv"], "text/tsv", cast(list[str], []))
TXT = (
"txt",
"text",
cast(list[str], []),
None,
[
".txt",
".text",
# NOTE(robinson) - for now we are treating code files as plain text
".c",
".cc",
".cpp",
".cs",
".cxx",
".go",
".java",
".js",
".log",
".php",
".py",
".rb",
".swift",
".ts",
".yaml",
".yml",
],
"text/plain",
[
# NOTE(robinson) - In the future, we may have special processing for YAML files
# instead of treating them as plaintext.
"text/yaml",
"application/x-yaml",
"application/yaml",
"text/x-yaml",
],
)
WAV = (
"wav",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".wav"],
"audio/wav",
[
"audio/vnd.wav",
"audio/vnd.wave",
"audio/wave",
"audio/x-pn-wav",
"audio/x-wav",
],
)
WEBM = (
"webm",
"audio",
cast(list[str], []), # STT agent deps validated at runtime by the chosen agent
"audio",
[".webm"],
"audio/webm",
[], # Do not alias video/webm: WebM is a container; this type is for audio-only.
)
XLS = (
"xls",
"xlsx",
["pandas", "openpyxl"],
"xlsx",
[".xls"],
"application/vnd.ms-excel",
cast(list[str], []),
)
XLSX = (
"xlsx",
"xlsx",
["pandas", "openpyxl"],
"xlsx",
[".xlsx"],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
cast(list[str], []),
)
XML = ("xml", "xml", cast(list[str], []), None, [".xml"], "application/xml", ["text/xml"])
ZIP = ("zip", None, cast(list[str], []), None, [".zip"], "application/zip", cast(list[str], []))
UNK = (
"unk",
None,
cast(list[str], []),
None,
cast(list[str], []),
"application/octet-stream",
cast(list[str], []),
)
EMPTY = (
"empty",
None,
cast(list[str], []),
None,
cast(list[str], []),
"inode/x-empty",
cast(list[str], []),
)
def create_file_type(
name: str,
*,
canonical_mime_type: str,
importable_package_dependencies: Iterable[str] | None = None,
extra_name: str | None = None,
extensions: Iterable[str] | None = None,
alias_mime_types: Iterable[str] | None = None,
) -> FileType:
"""Register a new FileType member."""
type_ = _create_file_type_enum(
FileType,
name,
None,
importable_package_dependencies or cast(list[str], []),
extra_name,
extensions or cast(list[str], []),
canonical_mime_type,
alias_mime_types or cast(list[str], []),
None,
)
type_._name_ = name
FileType._member_map_[name] = type_
return type_
_P = ParamSpec("_P")
def register_partitioner(
file_type: FileType,
) -> Callable[[Callable[_P, list[Element]]], Callable[_P, list[Element]]]:
def decorator(func: Callable[_P, list[Element]]) -> Callable[_P, list[Element]]:
file_type._partitioner_full_module_path = func.__module__ + "." + func.__name__
return func
return decorator
+67
View File
@@ -0,0 +1,67 @@
"""
Adds support for working with newline-delimited JSON (ndjson) files. This format is useful for
streaming json content that would otherwise not be possible using raw JSON files.
"""
import json
from typing import IO, Any
def dumps(obj: list[dict[str, Any]], **kwargs) -> str:
"""
Converts the list of dictionaries into string representation
Args:
obj (list[dict[str, Any]]): List of dictionaries to convert
**kwargs: Additional keyword arguments to pass to json.dumps
Returns:
str: string representation of the list of dictionaries
"""
return "\n".join(json.dumps(each, **kwargs) for each in obj)
def dump(obj: list[dict[str, Any]], fp: IO, **kwargs) -> None:
"""
Writes the list of dictionaries to a newline-delimited file
Args:
obj (list[dict[str, Any]]): List of dictionaries to convert
fp (IO): File pointer to write the string representation to
**kwargs: Additional keyword arguments to pass to json.dumps
Returns:
None
"""
# Indent breaks ndjson formatting
kwargs["indent"] = None
text = dumps(obj, **kwargs)
fp.write(text)
def loads(s: str, **kwargs) -> list[dict[str, Any]]:
"""
Converts the raw string into a list of dictionaries
Args:
s (str): Raw string to convert
**kwargs: Additional keyword arguments to pass to json.loads
Returns:
list[dict[str, Any]]: List of dictionaries parsed from the input string
"""
return [json.loads(line, **kwargs) for line in s.splitlines()]
def load(fp: IO, **kwargs) -> list[dict[str, Any]]:
"""
Converts the contents of the file into a list of dictionaries
Args:
fp (IO): File pointer to read the string representation from
**kwargs: Additional keyword arguments to pass to json.loads
Returns:
list[dict[str, Any]]: List of dictionaries parsed from the file
"""
return loads(fp.read(), **kwargs)