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
297 lines
10 KiB
Python
297 lines
10 KiB
Python
"""Environment and capability diagnostics for optional dependencies and system tools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import platform
|
|
import shutil
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
from unstructured.__version__ import __version__
|
|
from unstructured.file_utils.model import FileType
|
|
from unstructured.utils import dependency_exists
|
|
|
|
Status = Literal["ok", "missing", "warn"]
|
|
|
|
# File types that rely on LibreOffice for conversion (see partition/common/common.py).
|
|
_FILE_TYPES_NEEDING_SOFFICE: frozenset[FileType] = frozenset({FileType.DOC, FileType.PPT})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CapabilityResult:
|
|
"""Result of checking whether partitioning is viable for a target file type."""
|
|
|
|
ready: bool
|
|
"""True when Python deps and required external tools for this type are satisfied."""
|
|
messages: tuple[str, ...]
|
|
"""Human-readable issues (blocking or informational)."""
|
|
|
|
|
|
def _pip_hint_for_file_type(file_type: FileType) -> str:
|
|
extra = file_type.extra_name
|
|
if extra:
|
|
return f'pip install "unstructured[{extra}]"'
|
|
return "pip install unstructured"
|
|
|
|
|
|
def _needs_pandoc_runtime(file_type: FileType) -> bool:
|
|
return "pypandoc" in file_type.importable_package_dependencies
|
|
|
|
|
|
def _audio_extra_import_ok() -> bool:
|
|
return importlib.util.find_spec("whisper") is not None
|
|
|
|
|
|
def _libmagic_status() -> tuple[Status, str]:
|
|
try:
|
|
import magic
|
|
|
|
_ = magic.from_buffer(b"%PDF-1.4\n", mime=True)
|
|
except ImportError:
|
|
return "warn", "python `magic` module not available (filetype fallback still works)"
|
|
except Exception as exc: # noqa: BLE001 — surface libmagic load/binary issues
|
|
return "warn", f"libmagic not usable: {exc!s}"[:200]
|
|
else:
|
|
return "ok", "libmagic (python-magic) OK"
|
|
|
|
|
|
def _tool_status(name: str, on_path: bool) -> tuple[Status, str]:
|
|
if on_path:
|
|
return "ok", f"{name} found on PATH"
|
|
return "missing", f"{name} not found on PATH"
|
|
|
|
|
|
def python_import_deps_ok(file_type: FileType) -> tuple[bool, list[str]]:
|
|
"""Return whether declared importable dependencies for `file_type` are importable."""
|
|
missing: list[str] = []
|
|
for mod in file_type.importable_package_dependencies:
|
|
if not dependency_exists(mod):
|
|
missing.append(mod)
|
|
return len(missing) == 0, missing
|
|
|
|
|
|
def evaluate_file_type_capability(file_type: FileType) -> CapabilityResult:
|
|
"""Check partitioning readiness for a single :class:`FileType`."""
|
|
if not file_type.is_partitionable:
|
|
return CapabilityResult(
|
|
ready=False,
|
|
messages=(f"{file_type.name} is not partitionable.",),
|
|
)
|
|
|
|
ok, missing = python_import_deps_ok(file_type)
|
|
messages: list[str] = []
|
|
if not ok:
|
|
messages.append(
|
|
f"Missing Python module(s): {', '.join(missing)}. "
|
|
f"Install with: {_pip_hint_for_file_type(file_type)}",
|
|
)
|
|
|
|
if _needs_pandoc_runtime(file_type) and ok and shutil.which("pandoc") is None:
|
|
ok = False
|
|
messages.append(
|
|
"Pandoc executable not found on PATH (required by pypandoc). "
|
|
"Install pandoc from https://pandoc.org/installing.html",
|
|
)
|
|
|
|
if file_type in _FILE_TYPES_NEEDING_SOFFICE and ok and _libreoffice_on_path() is None:
|
|
ok = False
|
|
messages.append(
|
|
"soffice (LibreOffice CLI) not found on PATH. "
|
|
"Required to convert legacy .doc / .ppt to Open XML formats.",
|
|
)
|
|
|
|
# Audio partitioner uses the `audio` extra (Whisper); FileType members only list empty deps.
|
|
if file_type.extra_name == "audio":
|
|
if not _audio_extra_import_ok():
|
|
ok = False
|
|
messages.append(
|
|
"Missing audio extra (e.g. Whisper). "
|
|
'Install with: pip install "unstructured[audio]"',
|
|
)
|
|
if shutil.which("ffmpeg") is None:
|
|
ok = False
|
|
messages.append(
|
|
"ffmpeg not on PATH - required for Whisper audio decoding "
|
|
"(https://ffmpeg.org/download.html).",
|
|
)
|
|
|
|
return CapabilityResult(ready=ok, messages=tuple(messages))
|
|
|
|
|
|
def _libreoffice_on_path() -> str | None:
|
|
"""Return ``soffice`` on PATH; matches :func:`convert_office_doc` in ``partition.common``."""
|
|
return shutil.which("soffice")
|
|
|
|
|
|
def resolve_specifier(spec: str) -> list[FileType]:
|
|
"""Map a user string (e.g. ``pdf``, ``png``, ``image``) to :class:`FileType` members."""
|
|
raw = spec.strip()
|
|
if not raw:
|
|
raise ValueError("Empty specifier")
|
|
lower = raw.lower()
|
|
|
|
if lower == "image":
|
|
return [ft for ft in FileType if ft.is_partitionable and ft.extra_name == "image"]
|
|
if lower == "audio":
|
|
return [ft for ft in FileType if ft.is_partitionable and ft.extra_name == "audio"]
|
|
|
|
matches: list[FileType] = []
|
|
for ft in FileType:
|
|
if not ft.is_partitionable:
|
|
continue
|
|
if ft.name.lower() == lower or ft.value == lower:
|
|
matches.append(ft)
|
|
continue
|
|
if ft.partitioner_shortname and ft.partitioner_shortname.lower() == lower:
|
|
matches.append(ft)
|
|
|
|
if not matches:
|
|
valid = sorted(
|
|
{ft.value for ft in FileType if ft.is_partitionable}
|
|
| {ft.name.lower() for ft in FileType if ft.is_partitionable}
|
|
| {"image", "audio"},
|
|
)
|
|
sample = ", ".join(valid[:20])
|
|
raise ValueError(f"Unknown file type or alias {spec!r}. Examples: {sample}...")
|
|
|
|
# Prefer exact value/name matches over partitioner_shortname duplicates.
|
|
exact = [ft for ft in matches if ft.value == lower or ft.name.lower() == lower]
|
|
return exact or matches
|
|
|
|
|
|
def evaluate_specifier(spec: str) -> CapabilityResult:
|
|
"""Combine evaluation for all file types matched by :func:`resolve_specifier`."""
|
|
targets = resolve_specifier(spec)
|
|
if (
|
|
len(targets) > 1
|
|
and targets[0].extra_name in ("image", "audio")
|
|
and all(t.extra_name == targets[0].extra_name for t in targets)
|
|
):
|
|
targets = [targets[0]]
|
|
combined_ready = True
|
|
all_messages: list[str] = []
|
|
for ft in targets:
|
|
result = evaluate_file_type_capability(ft)
|
|
if not result.ready:
|
|
combined_ready = False
|
|
for m in result.messages:
|
|
all_messages.append(f"[{ft.name}] {m}")
|
|
# De-duplicate messages while preserving order
|
|
seen: set[str] = set()
|
|
deduped: list[str] = []
|
|
for m in all_messages:
|
|
if m not in seen:
|
|
seen.add(m)
|
|
deduped.append(m)
|
|
return CapabilityResult(ready=combined_ready, messages=tuple(deduped))
|
|
|
|
|
|
def environment_rows() -> list[tuple[str, str, str]]:
|
|
"""Rows for the environment section: (name, status, detail)."""
|
|
return [
|
|
("Python", "ok", platform.python_version()),
|
|
("Platform", "ok", platform.platform()),
|
|
("unstructured", "ok", __version__),
|
|
]
|
|
|
|
|
|
def system_tool_rows() -> list[tuple[str, Status, str]]:
|
|
"""Rows for optional system tools."""
|
|
rows: list[tuple[str, Status, str]] = []
|
|
st, detail = _libmagic_status()
|
|
rows.append(("libmagic (MIME detection)", st, detail))
|
|
|
|
for label, cmd in (
|
|
("tesseract (OCR)", "tesseract"),
|
|
("pandoc", "pandoc"),
|
|
("ffmpeg (audio/codecs)", "ffmpeg"),
|
|
):
|
|
status, msg = _tool_status(label, shutil.which(cmd) is not None)
|
|
rows.append((label, status, msg))
|
|
lo = _libreoffice_on_path()
|
|
rows.append(
|
|
(
|
|
"soffice (LibreOffice CLI)",
|
|
"ok" if lo else "missing",
|
|
f"found: {lo}" if lo else "not found on PATH",
|
|
),
|
|
)
|
|
return rows
|
|
|
|
|
|
def partitionable_file_type_rows() -> list[tuple[str, str, str, str]]:
|
|
"""One row per partitionable file type: type, deps OK, extra, notes."""
|
|
out: list[tuple[str, str, str, str]] = []
|
|
for ft in sorted(
|
|
(m for m in FileType if m.is_partitionable),
|
|
key=lambda x: x.name,
|
|
):
|
|
py_ok, _missing = python_import_deps_ok(ft)
|
|
deps_cell = "yes" if py_ok else "no"
|
|
extra = ft.extra_name or "-"
|
|
cap = evaluate_file_type_capability(ft)
|
|
notes = " | ".join(cap.messages) if cap.messages else "-"
|
|
out.append((ft.name, deps_cell, extra, notes))
|
|
return out
|
|
|
|
|
|
def format_table(headers: tuple[str, ...], rows: list[tuple[str, ...]]) -> str:
|
|
"""Render a simple fixed-width table (no third-party deps)."""
|
|
if not rows:
|
|
return " | ".join(headers) + "\n(no rows)\n"
|
|
col_widths = [len(h) for h in headers]
|
|
str_rows: list[list[str]] = []
|
|
for row in rows:
|
|
cells = [str(c) for c in row]
|
|
str_rows.append(cells)
|
|
for i, cell in enumerate(cells):
|
|
col_widths[i] = max(col_widths[i], len(cell))
|
|
sep = "-+-".join("-" * w for w in col_widths)
|
|
lines = [
|
|
" | ".join(headers[i].ljust(col_widths[i]) for i in range(len(headers))),
|
|
sep,
|
|
]
|
|
for cells in str_rows:
|
|
lines.append(" | ".join(cells[i].ljust(col_widths[i]) for i in range(len(headers))))
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def build_report() -> str:
|
|
"""Full diagnostic report for :command:`unstructured doctor` with no filter."""
|
|
parts: list[str] = []
|
|
parts.append("Environment\n")
|
|
parts.append(format_table(("Component", "Status", "Detail"), environment_rows()))
|
|
|
|
parts.append("System tools (optional but commonly needed)\n")
|
|
sys_rows = system_tool_rows()
|
|
parts.append(format_table(("Tool", "Status", "Detail"), sys_rows))
|
|
|
|
parts.append("Partitionable file types (Python extras)\n")
|
|
p_rows = partitionable_file_type_rows()
|
|
parts.append(
|
|
format_table(
|
|
("File type", "Python deps OK", "pip extra", "Notes"),
|
|
[tuple(r) for r in p_rows],
|
|
),
|
|
)
|
|
return "".join(parts)
|
|
|
|
|
|
def file_path_to_capability(path: str | Path) -> CapabilityResult:
|
|
"""Infer file type from ``path`` and evaluate capability."""
|
|
p = Path(path)
|
|
if not p.is_file():
|
|
return CapabilityResult(ready=False, messages=(f"Not a file or not found: {p}",))
|
|
|
|
from unstructured.file_utils.filetype import detect_filetype
|
|
|
|
ft = detect_filetype(file_path=str(p.resolve()))
|
|
if not ft.is_partitionable:
|
|
return CapabilityResult(
|
|
ready=False,
|
|
messages=(f"Detected type {ft.name} is not partitionable.",),
|
|
)
|
|
return evaluate_file_type_capability(ft)
|