Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ RUN apt-get update && apt-get upgrade -y && \
WORKDIR ${APP_HOME}
COPY . ./

# Install the package with the optional OCR backend, then pre-fetch NLTK data and
# warm the bundled OCR models so the service can start fully offline. Only the
# model warm-up may fail softly; a failed dependency install fails the build.
RUN uv sync --frozen --extra ocr --no-dev && \
# Install the full service runtime with OCR, then pre-fetch NLTK data and warm
# the bundled OCR models so the service can start fully offline. Only the model
# warm-up may fail softly; a failed dependency install fails the build.
RUN uv sync --frozen --extra all --no-dev && \
python -m nltk.downloader -d /usr/share/nltk_data punkt punkt_tab stopwords && \
{ python -c "from rapidocr_onnxruntime import RapidOCR; RapidOCR()" || true; }

Expand Down
9 changes: 5 additions & 4 deletions Dockerfile.test
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR ${APP_HOME}
COPY . .

# Dev group provides pytest. The OCR model warm-up alone is allowed to fail
# (it only pre-fetches; the service degrades gracefully without it) — a failed
# dependency install must fail the build, so no blanket `|| true` here.
RUN uv sync --frozen --extra ocr --group dev && \
# Dev group provides pytest and service test dependencies. The OCR model warm-up
# alone is allowed to fail (it only pre-fetches; the service degrades gracefully
# without it) — a failed dependency install must fail the build, so no blanket
# `|| true` here.
RUN uv sync --frozen --extra all --group dev && \
python -m nltk.downloader -d /usr/share/nltk_data punkt punkt_tab stopwords && \
{ python -c "from rapidocr_onnxruntime import RapidOCR; RapidOCR()" || true; }

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ run_fmt: format
check: lint

generate_requirements:
uv export --format requirements.txt --extra ocr --group dev --output-file requirements.txt
uv export --format requirements.txt --extra all --group dev --output-file requirements.txt

download_nltk_data:
uv run python -m nltk.downloader punkt punkt_tab stopwords
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,33 @@ reproduction commands are in [benchmarks/parsebench/RESULTS.md](benchmarks/parse
## Installation

```bash
# install the project and dev tools with uv
# parser-only install
pip install "warp-ingest[parser]"

# hosted service runtime (FastAPI/uvicorn)
pip install "warp-ingest[service]"

# full service runtime with OCR support
pip install "warp-ingest[all]"

# install the project and dev/test tools with uv
# (dev includes service dependencies because the test suite covers the API)
uv sync --group dev

# include the optional OCR backend for scanned PDFs
uv sync --group dev --extra ocr

# full service runtime with OCR
uv sync --group dev --extra all

# one-time NLTK data download
uv run python -m nltk.downloader punkt punkt_tab stopwords
```

## Running the service

```bash
# install with the `service` or `all` extra first
python -m warp_ingest.ingestion_daemon # or: ./run.sh (FastAPI/uvicorn, port 5001)
```

Expand Down
30 changes: 27 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,21 @@ classifiers = [
]
dependencies = [
"beautifulsoup4>=4.12.0,<5.0.0",
"fastapi>=0.115,<1.0",
"nltk>=3.9.1",
"numpy>=1.24,<3",
"pdfplumber>=0.11.4,<0.12.0",
"pypdfium2>=5",
"python-multipart>=0.0.9",
"uvicorn>=0.30,<1.0",
]

[project.optional-dependencies]
# Parser dependencies live in the base install. This no-op extra exists so
# `pip install warp-ingest[parser]` is an explicit parser-only spelling.
parser = []
service = [
"fastapi>=0.115,<1.0",
"python-multipart>=0.0.9",
"uvicorn>=0.30,<1.0",
]
# opencv is rapidocr's image-ops backend (resize/normalize/perspective-crop
# around the ONNX models); the headless build has the identical cv2 API without
# linking GUI libs (libGL), which a server image doesn't have. rapidocr's own
Expand All @@ -51,6 +56,22 @@ ocr = [
"shapely>=2.1.2",
"six>=1.17.0",
]
# Full runtime install for the hosted ingestion service. The benchmark-only
# `tables` extra remains separate because it pulls an ablation provider, not a
# normal production dependency.
all = [
"fastapi>=0.115,<1.0",
"onnxruntime>=1.17.0,<1.23; python_version == '3.10'",
"onnxruntime>=1.27.0; python_version >= '3.11' and python_version < '3.15'",
"opencv-python-headless>=4.13.0.92",
"pyclipper>=1.4.0",
"python-multipart>=0.0.9",
"pyyaml>=6.0.3",
"rapidocr-onnxruntime>=1.3.0,<2.0.0",
"shapely>=2.1.2",
"six>=1.17.0",
"uvicorn>=0.30,<1.0",
]
# Benchmark-ablation baseline for the pluggable table provider. Warp's own
# native table engine (warp_ingest/ingestor/table_engine.py) is the default and
# needs nothing beyond the core install; this extra exists only to reproduce
Expand All @@ -67,10 +88,13 @@ Issues = "https://github.com/Open-Source-Legal/Warp-Ingest/issues"
[dependency-groups]
dev = [
"black>=26.3.1",
"fastapi>=0.115,<1.0",
"httpx>=0.27",
"isort>=5.13.2",
"pytest>=9.0.3",
"python-multipart>=0.0.9",
"reportlab>=4.0",
"uvicorn>=0.30,<1.0",
]

[build-system]
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv export --format requirements.txt --extra ocr --group dev --output-file requirements.txt
# uv export --format requirements.txt --extra all --group dev --output-file requirements.txt
-e .
annotated-doc==0.0.4 \
--hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \
Expand Down
99 changes: 99 additions & 0 deletions tests/test_package_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Package metadata guards for optional install profiles."""

import re
from pathlib import Path

import pytest

try:
import tomllib
except ModuleNotFoundError: # Python 3.10
import tomli as tomllib

from warp_ingest.ingestion_daemon import __main__ as service_main
from warp_ingest.ingestion_daemon.service_dependencies import (
MissingServiceExtraError,
require_service_dependency,
)

PROJECT_ROOT = Path(__file__).resolve().parents[1]


def _metadata():
return tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text())


def _requirement_names(requirements):
names = set()
for requirement in requirements:
name = re.split(r"\s*(?:\[|[<>=!~; ])", requirement, maxsplit=1)[0]
names.add(name.lower().replace("_", "-"))
return names


def test_base_install_is_parser_only():
project = _metadata()["project"]
base = _requirement_names(project["dependencies"])

assert {
"beautifulsoup4",
"nltk",
"numpy",
"pdfplumber",
"pypdfium2",
} <= base
assert {"fastapi", "python-multipart", "uvicorn"}.isdisjoint(base)
assert project["optional-dependencies"]["parser"] == []


def test_service_and_all_extras_are_explicit_profiles():
extras = _metadata()["project"]["optional-dependencies"]
service = _requirement_names(extras["service"])
ocr = _requirement_names(extras["ocr"])
all_extra = _requirement_names(extras["all"])

assert {"fastapi", "python-multipart", "uvicorn"} <= service
assert service <= all_extra
assert ocr <= all_extra
assert "pymupdf4llm" not in all_extra


def test_missing_service_dependency_has_extra_install_hint():
def missing_dependency(_module_name):
raise ModuleNotFoundError(name="uvicorn")

with pytest.raises(MissingServiceExtraError) as exc:
require_service_dependency("uvicorn", importer=missing_dependency)

message = str(exc.value)
assert 'pip install "warp-ingest[service]"' in message
assert 'pip install "warp-ingest[all]"' in message
assert "Missing dependency: uvicorn." in message


def test_transitive_import_errors_are_not_masked():
def missing_transitive(_module_name):
raise ModuleNotFoundError(name="h11")

with pytest.raises(ModuleNotFoundError) as exc:
require_service_dependency("uvicorn", importer=missing_transitive)

assert exc.value.name == "h11"


def test_service_launcher_exits_with_install_hint(monkeypatch):
def missing_service_dependency(_module_name, package_name=None):
raise MissingServiceExtraError(
f"install service extra for {package_name or _module_name}"
)

monkeypatch.setattr(
service_main,
"require_service_dependency",
missing_service_dependency,
)

with pytest.raises(SystemExit) as exc:
service_main.main()

assert str(exc.value) == "install service extra for uvicorn"
46 changes: 39 additions & 7 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 12 additions & 3 deletions warp_ingest/ingestion_daemon/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,31 @@

import os

import uvicorn

import warp_ingest.ingestion_daemon.config as cfg
from warp_ingest.ingestion_daemon.auth import DEFAULT_API_KEY, expected_api_key
from warp_ingest.ingestion_daemon.autotune import compute_settings
from warp_ingest.ingestion_daemon.service_dependencies import (
MissingServiceExtraError,
require_service_dependency,
)


def main():
try:
uvicorn = require_service_dependency("uvicorn")
require_service_dependency("fastapi")
except MissingServiceExtraError as exc:
raise SystemExit(str(exc)) from None

from warp_ingest.ingestion_daemon.auth import DEFAULT_API_KEY, expected_api_key

settings = compute_settings()
# Export the computed budget for the worker processes (a user-set variable
# is already reflected in `settings` and setdefault leaves it alone).
os.environ.setdefault("WARP_FE_WORKERS", str(settings.fe_workers))
os.environ.setdefault("WARP_OCR_THREADS", str(settings.ocr_threads))
os.environ.setdefault("WARP_WORKER_PARSE_SLOTS", str(settings.parse_slots))
print(
"Starting Warp-Ingest service: "

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs sensitive data (password) as clear text.
f"cpus={settings.cpus} web_workers={settings.web_workers} "
f"fe_workers/worker={settings.fe_workers} ocr_threads={settings.ocr_threads} "
f"parse_slots/worker={settings.parse_slots} "
Expand Down
Loading
Loading