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
193 lines
7.2 KiB
Python
193 lines
7.2 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import importlib
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import sysconfig
|
|
import tempfile
|
|
import urllib.error
|
|
import urllib.request
|
|
from functools import lru_cache
|
|
from typing import Final, List, Tuple
|
|
|
|
import spacy
|
|
from filelock import FileLock
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CACHE_MAX_SIZE: Final[int] = 128
|
|
|
|
_SPACY_MODEL_NAME: Final[str] = "en_core_web_sm"
|
|
_SPACY_MODEL_VERSION: Final[str] = "3.8.0"
|
|
_SPACY_MODEL_URL: Final[str] = (
|
|
f"https://github.com/explosion/spacy-models/releases/download/"
|
|
f"{_SPACY_MODEL_NAME}-{_SPACY_MODEL_VERSION}/"
|
|
f"{_SPACY_MODEL_NAME}-{_SPACY_MODEL_VERSION}-py3-none-any.whl"
|
|
)
|
|
_SPACY_MODEL_SHA256: Final[str] = "1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85"
|
|
|
|
|
|
_DOWNLOAD_TIMEOUT_SECONDS: Final[int] = 120
|
|
_INSTALL_LOCK_PATH: Final[str] = os.path.join(
|
|
tempfile.gettempdir(), f"{_SPACY_MODEL_NAME}.install.lock"
|
|
)
|
|
|
|
|
|
def _download_with_timeout(url: str, dest: str) -> None:
|
|
"""Download a URL to a local file with a socket-level timeout."""
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=_DOWNLOAD_TIMEOUT_SECONDS) as resp:
|
|
with open(dest, "wb") as out:
|
|
shutil.copyfileobj(resp, out)
|
|
except urllib.error.URLError as exc:
|
|
raise RuntimeError(
|
|
f"Failed to download spaCy model from {url}: {exc}. "
|
|
"Check your network connection and try again."
|
|
) from exc
|
|
|
|
|
|
def _install_spacy_model() -> None:
|
|
"""Download and install the pinned spaCy model wheel using the `installer` library."""
|
|
from installer import install
|
|
from installer.destinations import SchemeDictionaryDestination
|
|
from installer.sources import WheelFile
|
|
from installer.utils import get_launcher_kind
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
whl_path = os.path.join(tmp, f"{_SPACY_MODEL_NAME}-{_SPACY_MODEL_VERSION}-py3-none-any.whl")
|
|
logger.info("Downloading spaCy model %s %s …", _SPACY_MODEL_NAME, _SPACY_MODEL_VERSION)
|
|
_download_with_timeout(_SPACY_MODEL_URL, whl_path)
|
|
|
|
with open(whl_path, "rb") as f:
|
|
sha256 = hashlib.sha256(f.read()).hexdigest()
|
|
if sha256 != _SPACY_MODEL_SHA256:
|
|
raise RuntimeError(
|
|
f"Hash mismatch for {_SPACY_MODEL_NAME}: "
|
|
f"expected {_SPACY_MODEL_SHA256}, got {sha256}"
|
|
)
|
|
|
|
# Install into a staging directory to avoid races with other processes
|
|
staging = os.path.join(tmp, "staging")
|
|
paths = sysconfig.get_paths()
|
|
staged_paths = paths.copy()
|
|
staged_paths["purelib"] = staging
|
|
staged_paths["platlib"] = staging
|
|
|
|
destination = SchemeDictionaryDestination(
|
|
staged_paths,
|
|
interpreter=sys.executable,
|
|
script_kind=get_launcher_kind(),
|
|
)
|
|
with WheelFile.open(whl_path) as source:
|
|
install(source=source, destination=destination, additional_metadata={})
|
|
|
|
# Move installed packages from staging into real site-packages.
|
|
# The caller holds _INSTALL_LOCK_PATH so no other process races here.
|
|
# Any dst that already exists is a remnant of a previous failed install
|
|
# (spacy.load() just failed), so remove it before moving to avoid
|
|
# shutil.move placing src *inside* an existing directory.
|
|
site_packages = paths["purelib"]
|
|
for item in os.listdir(staging):
|
|
src = os.path.join(staging, item)
|
|
dst = os.path.join(site_packages, item)
|
|
try:
|
|
if os.path.isdir(dst):
|
|
shutil.rmtree(dst)
|
|
elif os.path.exists(dst):
|
|
os.remove(dst)
|
|
shutil.move(src, dst)
|
|
except OSError as exc:
|
|
raise RuntimeError(
|
|
f"Failed to install {_SPACY_MODEL_NAME} to {site_packages}: {exc}. "
|
|
"Ensure the site-packages directory is writable, or pre-install the model "
|
|
f"with: python -m spacy download {_SPACY_MODEL_NAME}"
|
|
) from exc
|
|
|
|
logger.info("Installed %s %s", _SPACY_MODEL_NAME, _SPACY_MODEL_VERSION)
|
|
|
|
|
|
# Only tok2vec, tagger, parser (sentence boundaries), and sentencizer are used
|
|
# (pos_tag and sent_tokenize). Excluding the remaining components saves ~7 MiB
|
|
# of model weights per process.
|
|
_SPACY_EXCLUDE = ["ner", "lemmatizer", "attribute_ruler"]
|
|
|
|
|
|
def _load_spacy_model() -> spacy.language.Language:
|
|
try:
|
|
return spacy.load(_SPACY_MODEL_NAME, exclude=_SPACY_EXCLUDE)
|
|
except OSError:
|
|
pass
|
|
|
|
# Serialize model installation across processes with an exclusive file lock.
|
|
# A well-known path in the system temp dir is visible to all processes
|
|
# regardless of their working directory.
|
|
with FileLock(_INSTALL_LOCK_PATH, timeout=-1):
|
|
# Double-check: another process may have installed while we waited.
|
|
importlib.invalidate_caches()
|
|
try:
|
|
return spacy.load(_SPACY_MODEL_NAME, exclude=_SPACY_EXCLUDE)
|
|
except OSError:
|
|
pass
|
|
_install_spacy_model()
|
|
importlib.invalidate_caches()
|
|
try:
|
|
return spacy.load(_SPACY_MODEL_NAME, exclude=_SPACY_EXCLUDE)
|
|
except OSError as exc:
|
|
raise RuntimeError(
|
|
f"Installed {_SPACY_MODEL_NAME} but spacy.load() still failed. "
|
|
"Check site-packages permissions and installation integrity."
|
|
) from exc
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _get_nlp() -> spacy.language.Language:
|
|
"""Load the spaCy model on first use and cache it for the lifetime of the process."""
|
|
return _load_spacy_model()
|
|
|
|
|
|
def _process(text: str) -> spacy.tokens.Doc:
|
|
"""Run the spaCy pipeline once. All public functions extract what they need from the Doc."""
|
|
# -- str() handles numpy.str_ from OCR pipelines --
|
|
text = str(text)
|
|
nlp = _get_nlp()
|
|
if len(text) > nlp.max_length:
|
|
logger.warning(
|
|
"Input text of length %d exceeds spaCy max_length=%d; "
|
|
"truncating for partition heuristics.",
|
|
len(text),
|
|
nlp.max_length,
|
|
)
|
|
# Prefer to cut at the last whitespace within the budget so we don't split a token.
|
|
cut = text.rfind(" ", max(0, nlp.max_length - 256), nlp.max_length)
|
|
truncated = text[: cut if cut != -1 else nlp.max_length]
|
|
return nlp(truncated)
|
|
return nlp(text)
|
|
|
|
|
|
def sent_tokenize(text: str) -> List[str]:
|
|
"""A wrapper so that we can cache the result of sentence tokenization as an
|
|
immutable, while returning the expected return type (list)."""
|
|
return list(_tokenize_for_cache(text))
|
|
|
|
|
|
@lru_cache(maxsize=CACHE_MAX_SIZE)
|
|
def word_tokenize(text: str) -> List[str]:
|
|
"""A wrapper around the spaCy word tokenizer with LRU caching enabled."""
|
|
return [token.text for token in _process(text)]
|
|
|
|
|
|
@lru_cache(maxsize=CACHE_MAX_SIZE)
|
|
def pos_tag(text: str) -> List[Tuple[str, str]]:
|
|
"""A wrapper around the spaCy POS tagger with LRU caching enabled."""
|
|
doc = _process(text)
|
|
return [(token.text, token.tag_) for token in doc]
|
|
|
|
|
|
@lru_cache(maxsize=CACHE_MAX_SIZE)
|
|
def _tokenize_for_cache(text: str) -> Tuple[str, ...]:
|
|
"""A wrapper around the spaCy sentence tokenizer with LRU caching enabled."""
|
|
return tuple(sent.text for sent in _process(text).sents)
|