deeptutor-parsing 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. deeptutor/services/parsing/__init__.py +34 -0
  2. deeptutor/services/parsing/base.py +84 -0
  3. deeptutor/services/parsing/cache.py +203 -0
  4. deeptutor/services/parsing/engines/__init__.py +7 -0
  5. deeptutor/services/parsing/engines/_install.py +281 -0
  6. deeptutor/services/parsing/engines/_versions.py +38 -0
  7. deeptutor/services/parsing/engines/docling/__init__.py +6 -0
  8. deeptutor/services/parsing/engines/docling/config.py +59 -0
  9. deeptutor/services/parsing/engines/docling/engine.py +173 -0
  10. deeptutor/services/parsing/engines/docling/formats.py +137 -0
  11. deeptutor/services/parsing/engines/docling/local_worker.py +163 -0
  12. deeptutor/services/parsing/engines/docling/remote.py +192 -0
  13. deeptutor/services/parsing/engines/factory.py +216 -0
  14. deeptutor/services/parsing/engines/formats.py +29 -0
  15. deeptutor/services/parsing/engines/liteparse/__init__.py +7 -0
  16. deeptutor/services/parsing/engines/liteparse/config.py +65 -0
  17. deeptutor/services/parsing/engines/liteparse/engine.py +156 -0
  18. deeptutor/services/parsing/engines/liteparse/formats.py +58 -0
  19. deeptutor/services/parsing/engines/markitdown/__init__.py +6 -0
  20. deeptutor/services/parsing/engines/markitdown/config.py +31 -0
  21. deeptutor/services/parsing/engines/markitdown/engine.py +88 -0
  22. deeptutor/services/parsing/engines/markitdown/formats.py +102 -0
  23. deeptutor/services/parsing/engines/mineru/__init__.py +13 -0
  24. deeptutor/services/parsing/engines/mineru/_process.py +82 -0
  25. deeptutor/services/parsing/engines/mineru/backend.py +279 -0
  26. deeptutor/services/parsing/engines/mineru/checkpoints.py +98 -0
  27. deeptutor/services/parsing/engines/mineru/cloud.py +631 -0
  28. deeptutor/services/parsing/engines/mineru/config.py +143 -0
  29. deeptutor/services/parsing/engines/mineru/engine.py +87 -0
  30. deeptutor/services/parsing/engines/mineru/formats.py +46 -0
  31. deeptutor/services/parsing/engines/mineru/local.py +490 -0
  32. deeptutor/services/parsing/engines/mineru/models.py +228 -0
  33. deeptutor/services/parsing/engines/mineru/normalization.py +134 -0
  34. deeptutor/services/parsing/engines/mineru/readiness.py +121 -0
  35. deeptutor/services/parsing/engines/pymupdf4llm/__init__.py +8 -0
  36. deeptutor/services/parsing/engines/pymupdf4llm/config.py +36 -0
  37. deeptutor/services/parsing/engines/pymupdf4llm/engine.py +158 -0
  38. deeptutor/services/parsing/engines/pymupdf4llm/formats.py +60 -0
  39. deeptutor/services/parsing/engines/text_only/__init__.py +9 -0
  40. deeptutor/services/parsing/engines/text_only/engine.py +69 -0
  41. deeptutor/services/parsing/engines/tika/__init__.py +1 -0
  42. deeptutor/services/parsing/engines/tika/config.py +36 -0
  43. deeptutor/services/parsing/engines/tika/engine.py +68 -0
  44. deeptutor/services/parsing/engines/tika/formats.py +217 -0
  45. deeptutor/services/parsing/engines/tika/remote.py +143 -0
  46. deeptutor/services/parsing/progress.py +63 -0
  47. deeptutor/services/parsing/service.py +256 -0
  48. deeptutor/services/parsing/signature.py +50 -0
  49. deeptutor/services/parsing/types.py +52 -0
  50. deeptutor/textbook_struct/__init__.py +20 -0
  51. deeptutor/textbook_struct/chapter_rebuild.py +273 -0
  52. deeptutor/textbook_struct/column_blacklist.py +41 -0
  53. deeptutor/textbook_struct/page_headers.py +100 -0
  54. deeptutor_parsing-1.7.0.dist-info/METADATA +16 -0
  55. deeptutor_parsing-1.7.0.dist-info/RECORD +57 -0
  56. deeptutor_parsing-1.7.0.dist-info/WHEEL +5 -0
  57. deeptutor_parsing-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,34 @@
1
+ """Shared, engine-pluggable document-parsing layer (the "bridge").
2
+
3
+ Sits between input material and its consumers (question extraction, RAG
4
+ indexing, future LightRAG): one canonical IR (:class:`ParsedDocument`), one
5
+ content-addressed cache, a registry of pluggable engines (text-only, MinerU,
6
+ Docling, markitdown). Parsing is upstream of and independent from retrieval.
7
+
8
+ ``ParseService`` / ``get_parse_service`` are the public entry; they are imported
9
+ lazily here to keep this package importable before the service module lands and
10
+ to avoid pulling engine deps at import time.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .base import Parser, ReadinessReport
16
+ from .signature import ParserSignature
17
+ from .types import ParsedDocument, ParserError
18
+
19
+
20
+ def get_parse_service():
21
+ """Return the process-wide :class:`ParseService` singleton."""
22
+ from .service import get_parse_service as _get
23
+
24
+ return _get()
25
+
26
+
27
+ __all__ = [
28
+ "ParsedDocument",
29
+ "ParserError",
30
+ "Parser",
31
+ "ReadinessReport",
32
+ "ParserSignature",
33
+ "get_parse_service",
34
+ ]
@@ -0,0 +1,84 @@
1
+ """Structural contract every document-parsing engine implements.
2
+
3
+ Parsing is deliberately kept OFF the ``RAGPipeline`` protocol
4
+ (``services/rag/pipelines/base.py``): it is an upstream, shared, *optional*
5
+ stage. An engine turns bytes/path into a :class:`~deeptutor.services.parsing.types.ParsedDocument`
6
+ and declares its availability + model readiness so the UI can gate local model
7
+ downloads (default: no silent multi-GB pull).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, Callable, Optional, Protocol, runtime_checkable
15
+
16
+ from .signature import ParserSignature
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ReadinessReport:
21
+ """Whether an engine can run a parse right now."""
22
+
23
+ ready: bool
24
+ # Machine code: "ready" | "models_missing" | "cli_missing" | "not_configured"
25
+ reason: str = "ready"
26
+ # User-facing guidance shown when not ready (which escape hatch to take).
27
+ message: str = ""
28
+
29
+
30
+ @runtime_checkable
31
+ class Parser(Protocol):
32
+ """A document-parsing engine: ``source_path`` in → ``ParsedDocument`` out.
33
+
34
+ Concrete engines also expose a ``classmethod is_available() -> bool`` (probed
35
+ by the factory before instantiation); it is intentionally not part of the
36
+ instance Protocol.
37
+ """
38
+
39
+ name: str
40
+ # True when the engine downloads/needs local model weights (MinerU local,
41
+ # Docling). text-only, markitdown, and cloud backends are False.
42
+ needs_local_models: bool
43
+
44
+ def resolve_config(self) -> Any:
45
+ """Load this engine's effective config slice from runtime settings."""
46
+ ...
47
+
48
+ def supported_formats(self) -> frozenset[str]:
49
+ """Lower-case file suffixes (including the dot) this engine can parse.
50
+
51
+ Compound suffixes such as ``.dclg.xml`` and ``.tar.gz`` are allowed.
52
+ An empty set means the engine performs its own content-based/runtime
53
+ detection (for example a remote Tika server with custom parsers).
54
+ """
55
+ ...
56
+
57
+ def signature(self, config: Any) -> ParserSignature:
58
+ """Stable identity of ``(engine, version, output-affecting config)``."""
59
+ ...
60
+
61
+ def is_ready(self, config: Any) -> ReadinessReport:
62
+ """Whether a parse can run now (models present / cloud configured)."""
63
+ ...
64
+
65
+ def parse(
66
+ self,
67
+ source_path: Path,
68
+ workdir: Path,
69
+ *,
70
+ config: Any,
71
+ on_output: Optional[Callable[[str], None]] = None,
72
+ ) -> None:
73
+ """Write canonical artifacts for ``source_path`` into ``workdir``.
74
+
75
+ Engines emit ``<stem>.md`` (always) and may add
76
+ ``<stem>_content_list.json`` and an ``images/`` dir. The caller
77
+ (``ParseService``) assembles the :class:`ParsedDocument` from the
78
+ workdir via :func:`deeptutor.services.parsing.cache.load_ir` and writes
79
+ the cache manifest. Raises :class:`ParserError` on failure.
80
+ """
81
+ ...
82
+
83
+
84
+ __all__ = ["Parser", "ReadinessReport"]
@@ -0,0 +1,203 @@
1
+ """Content-addressed parse cache + canonical IR loader.
2
+
3
+ The cache is keyed by ``(source_hash, parser_signature)`` and shared across all
4
+ consumers (question extraction, RAG indexing). Re-parsing the same bytes with
5
+ the same engine config is a directory lookup; a different engine/version/knob
6
+ lands in a different signature dir and re-parses. Layout::
7
+
8
+ parse_cache/<hash[:2]>/<source_hash>/<signature>/
9
+ manifest.json # atomically written last → completed
10
+ <stem>.md
11
+ <stem>_content_list.json # optional (engines that emit structure)
12
+ images/ # optional
13
+
14
+ ``load_ir`` turns such a dir back into ``(markdown, blocks, asset_dir)``,
15
+ mirroring the question extractor's loader but free of stdout side effects so
16
+ both it and the engines share one loader.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from datetime import datetime, timezone
22
+ import hashlib
23
+ import json
24
+ import logging
25
+ from pathlib import Path
26
+ from typing import Any, Optional
27
+ from uuid import uuid4
28
+
29
+ from deeptutor.services.file_io import atomic_write_json
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ MANIFEST_FILENAME = "manifest.json"
34
+ _HASH_PREFIX = "sha256"
35
+ _READ_CHUNK = 1 << 20 # 1 MiB
36
+
37
+
38
+ def source_hash_from_path(path: Path) -> str:
39
+ """Hash the *bytes* of ``path`` (not its name) so re-uploads of the same
40
+ document under a random temp name still hit cache."""
41
+ digest = hashlib.sha256()
42
+ with open(path, "rb") as handle:
43
+ for chunk in iter(lambda: handle.read(_READ_CHUNK), b""):
44
+ digest.update(chunk)
45
+ return digest.hexdigest()[:16]
46
+
47
+
48
+ def signature_dir(cache_root: Path, source_hash: str, sig_hash: str) -> Path:
49
+ return cache_root / source_hash[:2] / source_hash / sig_hash
50
+
51
+
52
+ def is_ready(workdir: Optional[Path]) -> bool:
53
+ if workdir is None:
54
+ return False
55
+ try:
56
+ manifest = json.loads((workdir / MANIFEST_FILENAME).read_text(encoding="utf-8"))
57
+ return isinstance(manifest, dict) and bool(manifest.get("created_at"))
58
+ except (OSError, ValueError):
59
+ return False
60
+
61
+
62
+ def lookup(cache_root: Path, source_hash: str, sig_hash: str) -> Optional[Path]:
63
+ """Return a ready cache dir for the key, or ``None`` on miss."""
64
+ target = signature_dir(cache_root, source_hash, sig_hash)
65
+ return target if is_ready(target) else None
66
+
67
+
68
+ def reserve(cache_root: Path, source_hash: str, sig_hash: str) -> Path:
69
+ """Create (or reuse) the signature dir the engine writes its artifacts into.
70
+
71
+ Incomplete attempts are retained beside the cache entry for diagnosis, but
72
+ never reused as completed parser output (#1612).
73
+ """
74
+ target = signature_dir(cache_root, source_hash, sig_hash)
75
+ if target.exists() and not is_ready(target):
76
+ cleanup_failed(target)
77
+ if target.exists():
78
+ raise OSError(f"Could not retain incomplete parse output: {target}")
79
+ target.mkdir(parents=True, exist_ok=True)
80
+ return target
81
+
82
+
83
+ def write_manifest(workdir: Path, meta: dict[str, Any]) -> None:
84
+ """Stamp the cache dir ready. Written last so a half-written dir never reads
85
+ as a cache hit."""
86
+ payload = {
87
+ **meta,
88
+ "created_at": datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + "Z",
89
+ }
90
+ atomic_write_json(workdir / MANIFEST_FILENAME, payload)
91
+
92
+
93
+ def cleanup_failed(workdir: Path) -> None:
94
+ """Retain unfinished work outside the active signature directory."""
95
+ try:
96
+ if workdir.is_dir() and not is_ready(workdir):
97
+ retained = workdir.with_name(f".{workdir.name}.failed-{uuid4().hex}")
98
+ workdir.rename(retained)
99
+ logger.warning("Retained incomplete parse output at %s", retained)
100
+ except Exception as exc: # pragma: no cover - best-effort
101
+ logger.warning("Could not clean up failed parse dir %s: %s", workdir, exc)
102
+
103
+
104
+ def find_content_dir(workdir: Path) -> Path:
105
+ """Locate the dir holding parsed markdown artifacts.
106
+
107
+ Engines (MinerU especially) may nest output under ``auto/``/``hybrid_auto/``
108
+ or a per-document subdir. Mirrors the question extractor's resolver.
109
+ """
110
+ candidate_dirs: list[Path] = []
111
+
112
+ for preferred_name in ("auto", "hybrid_auto"):
113
+ preferred = workdir / preferred_name
114
+ if preferred.is_dir():
115
+ candidate_dirs.append(preferred)
116
+
117
+ for child in sorted(workdir.iterdir()) if workdir.is_dir() else []:
118
+ if child.is_dir() and child not in candidate_dirs:
119
+ candidate_dirs.append(child)
120
+
121
+ nested = {
122
+ artifact.parent
123
+ for pattern in ("*.md", "*_content_list.json")
124
+ for artifact in (workdir.rglob(pattern) if workdir.is_dir() else [])
125
+ }
126
+ for artifact_dir in sorted(nested):
127
+ if artifact_dir not in candidate_dirs:
128
+ candidate_dirs.append(artifact_dir)
129
+
130
+ for candidate in candidate_dirs:
131
+ if list(candidate.glob("*.md")):
132
+ return candidate
133
+
134
+ return candidate_dirs[0] if candidate_dirs else workdir
135
+
136
+
137
+ def _absolutize_img_paths(blocks: list[dict], content_dir: Path) -> list[dict]:
138
+ """Anchor relative ``img_path`` entries at ``content_dir``.
139
+
140
+ Engines (MinerU especially) emit ``img_path`` values like ``images/x.png``
141
+ relative to the content-list file. Consumers such as LightRAG resolve
142
+ them against their own working directory instead, so the images are never
143
+ found (issue #624). The cached JSON stays relative — paths are rewritten
144
+ only on load, keeping the cache dir relocatable.
145
+ """
146
+ for block in blocks:
147
+ if not isinstance(block, dict):
148
+ continue
149
+ img_path = block.get("img_path")
150
+ if not isinstance(img_path, str) or not img_path:
151
+ continue
152
+ p = Path(img_path)
153
+ if p.is_absolute():
154
+ continue
155
+ if ".." in p.parts:
156
+ logger.warning("Skipping traversal img_path in content_list: %s", img_path)
157
+ continue
158
+ block["img_path"] = str(content_dir / img_path)
159
+ return blocks
160
+
161
+
162
+ def load_ir(workdir: Path) -> tuple[str, Optional[list[dict]], Optional[Path]]:
163
+ """Load ``(markdown, blocks, asset_dir)`` from a parsed/cached dir.
164
+
165
+ ``markdown`` is "" if no ``.md`` exists; ``blocks`` is the parsed
166
+ ``*_content_list.json`` or ``None``; ``asset_dir`` is the ``images/`` dir if
167
+ present.
168
+ """
169
+ content_dir = find_content_dir(workdir)
170
+
171
+ markdown = ""
172
+ md_files = list(content_dir.glob("*.md"))
173
+ if md_files:
174
+ markdown = md_files[0].read_text(encoding="utf-8")
175
+
176
+ blocks: Optional[list[dict]] = None
177
+ json_files = list(content_dir.glob("*_content_list.json"))
178
+ if json_files:
179
+ try:
180
+ loaded = json.loads(json_files[0].read_text(encoding="utf-8"))
181
+ if isinstance(loaded, list):
182
+ blocks = _absolutize_img_paths(loaded, content_dir)
183
+ except Exception as exc:
184
+ logger.warning("Failed to read content_list %s: %s", json_files[0], exc)
185
+
186
+ images_dir = content_dir / "images"
187
+ asset_dir = images_dir if images_dir.is_dir() else None
188
+
189
+ return markdown, blocks, asset_dir
190
+
191
+
192
+ __all__ = [
193
+ "MANIFEST_FILENAME",
194
+ "source_hash_from_path",
195
+ "signature_dir",
196
+ "is_ready",
197
+ "lookup",
198
+ "reserve",
199
+ "write_manifest",
200
+ "cleanup_failed",
201
+ "find_content_dir",
202
+ "load_ir",
203
+ ]
@@ -0,0 +1,7 @@
1
+ """Pluggable document-parsing engines.
2
+
3
+ Each engine implements the :class:`~deeptutor.services.parsing.base.Parser`
4
+ contract and is selected by name through :mod:`deeptutor.services.parsing.engines.factory`.
5
+ Third-party imports are lazy so an engine whose dependency is absent simply
6
+ reports ``is_available() is False`` instead of breaking import.
7
+ """
@@ -0,0 +1,281 @@
1
+ """One-click background jobs for optional parser engines.
2
+
3
+ The Document Parsing settings page runs two kinds of background job without the
4
+ user dropping to a shell:
5
+
6
+ * **install** — ``pip install`` an engine's optional PyPI package.
7
+ * **models** — download an engine's model weights (e.g. Docling's layout/table
8
+ models via ``docling-tools models download``).
9
+
10
+ Both mirror the MinerU model-download job (``mineru/models.py``): a single
11
+ background subprocess with a cursor-based line log the UI starts, polls, and
12
+ cancels. Commands come from fixed allow-lists (pip specs / downloader argv),
13
+ never user input, so the subprocess argv can't be injected. Package installs use
14
+ the bare package specs (the same strings as the ``[parse-*]`` extras) so pip
15
+ never re-resolves DeepTutor itself; after a successful install we invalidate the
16
+ import caches so the engine reports available in the same process (no restart).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import importlib
22
+ import logging
23
+ import os
24
+ from pathlib import Path
25
+ import shutil
26
+ import subprocess # nosec B404 - argv from fixed allow-lists, never user input
27
+ import sys
28
+ import threading
29
+ import time
30
+ from typing import Any, Callable, Optional
31
+
32
+ from ._versions import package_version
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # Engine id -> pip requirement specifiers. Mirrors the optional extras in
37
+ # pyproject.toml. Engines absent here (text_only built-in, mineru external
38
+ # CLI/API) have no one-click install.
39
+ from deeptutor.modules.catalog import manifests
40
+
41
+ ENGINE_PIP_SPECS: dict[str, list[str]] = {
42
+ row["parser_engine"]: list(row["requirements"])
43
+ for row in manifests().values()
44
+ if "parser_engine" in row
45
+ }
46
+
47
+ # Engine id -> console-script argv that downloads its model weights. The script
48
+ # is resolved next to the server's python first (same env), then on PATH.
49
+ ENGINE_MODEL_DOWNLOADERS: dict[str, list[str]] = {
50
+ "docling": ["docling-tools", "models", "download"],
51
+ }
52
+
53
+ # Buffered log lines kept in memory; older lines are dropped (the cursor
54
+ # protocol keeps clients consistent across trims).
55
+ _MAX_LINES = 2000
56
+ _LINE_MIN_INTERVAL = 0.2
57
+
58
+
59
+ def installable_engines() -> frozenset[str]:
60
+ """Engine ids that support one-click pip install."""
61
+ return frozenset(ENGINE_PIP_SPECS)
62
+
63
+
64
+ def model_downloadable_engines() -> frozenset[str]:
65
+ """Engine ids that support one-click model-weight download."""
66
+ return frozenset(ENGINE_MODEL_DOWNLOADERS)
67
+
68
+
69
+ def resolve_model_downloader(engine: str) -> Optional[list[str]]:
70
+ """Resolve the model-download argv for ``engine``.
71
+
72
+ Locates the console script next to the server's python first (same env so it
73
+ matches the installed engine), then falls back to PATH. Returns ``None`` if
74
+ the engine has no downloader or the script isn't found.
75
+ """
76
+ parts = ENGINE_MODEL_DOWNLOADERS.get(engine)
77
+ if not parts:
78
+ return None
79
+ name = parts[0]
80
+ # Let the platform resolver search the active Python environment too. On
81
+ # Windows pip installs console scripts as ``<name>.exe`` and ``which``
82
+ # applies PATHEXT; probing the extensionless Path directly misses it.
83
+ sibling = shutil.which(name, path=str(Path(sys.executable).parent))
84
+ if sibling:
85
+ return [sibling, *parts[1:]]
86
+ found = shutil.which(name)
87
+ if found:
88
+ return [found, *parts[1:]]
89
+ return None
90
+
91
+
92
+ def _invalidate_import_caches() -> None:
93
+ """Make a freshly installed package importable in this process and bust the
94
+ cached "" version so readiness flips without a server restart."""
95
+ try:
96
+ importlib.invalidate_caches()
97
+ package_version.cache_clear()
98
+ from .markitdown.formats import markitdown_supported_formats
99
+
100
+ # Docling format discovery is intentionally static and has no cache to
101
+ # invalidate: importing its runtime would pull PyTorch's OpenMP runtime
102
+ # into the FAISS backend process on macOS.
103
+ markitdown_supported_formats.cache_clear()
104
+ except Exception: # noqa: BLE001 - best effort
105
+ logger.exception("Post-install cache invalidation failed")
106
+
107
+
108
+ class BackgroundJobManager:
109
+ """At most one background subprocess (install or model download), with a
110
+ cursor-based line log.
111
+
112
+ States: ``idle`` → ``running`` → ``done`` / ``failed`` / ``cancelled``.
113
+ ``status(cursor)`` returns lines after ``cursor`` plus ``next_cursor`` so the
114
+ UI can poll incrementally; ``kind`` (``install`` | ``models``) and ``engine``
115
+ tell the UI which job/card the log belongs to.
116
+ """
117
+
118
+ def __init__(self) -> None:
119
+ self._lock = threading.Lock()
120
+ self._state = "idle"
121
+ self._kind = ""
122
+ self._engine = ""
123
+ self._lines: list[str] = []
124
+ self._base = 0
125
+ self._message = ""
126
+ self._process: subprocess.Popen | None = None
127
+ self._cancel_requested = False
128
+
129
+ def start_install(self, *, engine: str, specs: list[str]) -> dict[str, Any]:
130
+ # Host-package changes cannot be safe while turns/API workers run.
131
+ # Model downloads remain background jobs because they do not mutate code.
132
+ if engine not in ENGINE_PIP_SPECS or specs != ENGINE_PIP_SPECS[engine]:
133
+ return {"ok": False, "message": "Unknown parser installation request."}
134
+ return {
135
+ "ok": False,
136
+ "message": (
137
+ "Stop DeepTutor, then run `deeptutor module install parser-"
138
+ + engine
139
+ + "` in this environment and restart. User data is preserved."
140
+ ),
141
+ }
142
+
143
+ def start_model_download(self, *, engine: str, cmd: list[str]) -> dict[str, Any]:
144
+ return self._launch(kind="models", engine=engine, cmd=cmd)
145
+
146
+ def status(self, cursor: int = 0) -> dict[str, Any]:
147
+ with self._lock:
148
+ start = max(int(cursor) - self._base, 0)
149
+ return {
150
+ "state": self._state,
151
+ "kind": self._kind,
152
+ "engine": self._engine,
153
+ "lines": list(self._lines[start:]),
154
+ "next_cursor": self._base + len(self._lines),
155
+ "message": self._message,
156
+ }
157
+
158
+ def cancel(self) -> dict[str, Any]:
159
+ with self._lock:
160
+ process = self._process
161
+ running = self._state == "running"
162
+ if running:
163
+ self._cancel_requested = True
164
+ if not (running and process):
165
+ return {"ok": False, "message": "No job is running."}
166
+ if process.poll() is None:
167
+ try:
168
+ process.terminate()
169
+ except Exception as exc: # noqa: BLE001
170
+ return {"ok": False, "message": f"Failed to cancel: {exc}"}
171
+ return {"ok": True, "message": ""}
172
+
173
+ # ------------------------------------------------------------------
174
+
175
+ def _launch(
176
+ self,
177
+ *,
178
+ kind: str,
179
+ engine: str,
180
+ cmd: list[str],
181
+ env: Optional[dict[str, str]] = None,
182
+ on_success: Optional[Callable[[], None]] = None,
183
+ ) -> dict[str, Any]:
184
+ with self._lock:
185
+ if self._state == "running":
186
+ return {
187
+ "ok": False,
188
+ "message": f"A {self._kind or 'job'} is already running ({self._engine}).",
189
+ }
190
+ try:
191
+ process = subprocess.Popen( # nosec B603 - argv from fixed allow-lists
192
+ cmd,
193
+ stdout=subprocess.PIPE,
194
+ stderr=subprocess.STDOUT,
195
+ text=True,
196
+ shell=False,
197
+ env={**os.environ, **(env or {})},
198
+ )
199
+ except Exception as exc: # noqa: BLE001
200
+ self._state = "failed"
201
+ self._kind = kind
202
+ self._engine = engine
203
+ self._message = f"Failed to launch: {exc}"
204
+ return {"ok": False, "message": self._message}
205
+ self._state = "running"
206
+ self._kind = kind
207
+ self._engine = engine
208
+ self._lines = []
209
+ self._base = 0
210
+ self._message = ""
211
+ self._process = process
212
+ self._cancel_requested = False
213
+ thread = threading.Thread(
214
+ target=self._pump, args=(process, kind, engine, on_success), daemon=True
215
+ )
216
+ thread.start()
217
+ logger.info("Parser %s job started (%s): %s", kind, engine, " ".join(cmd))
218
+ return {"ok": True, "message": ""}
219
+
220
+ def _pump(
221
+ self,
222
+ process: subprocess.Popen,
223
+ kind: str,
224
+ engine: str,
225
+ on_success: Optional[Callable[[], None]],
226
+ ) -> None:
227
+ last_emit = 0.0
228
+ try:
229
+ assert process.stdout is not None
230
+ for raw_line in process.stdout:
231
+ line = raw_line.strip()
232
+ if not line:
233
+ continue
234
+ now = time.monotonic()
235
+ if now - last_emit < _LINE_MIN_INTERVAL:
236
+ continue
237
+ last_emit = now
238
+ self._append(line[:300])
239
+ except Exception:
240
+ logger.exception("Background job output pump failed")
241
+ returncode = process.wait()
242
+ with self._lock:
243
+ if self._cancel_requested:
244
+ self._state = "cancelled"
245
+ self._message = "Cancelled."
246
+ elif returncode == 0:
247
+ self._state = "done"
248
+ self._message = "Finished."
249
+ else:
250
+ self._state = "failed"
251
+ self._message = f"Exited with code {returncode}."
252
+ self._process = None
253
+ if returncode == 0 and not self._cancel_requested and on_success is not None:
254
+ on_success()
255
+ logger.info("Parser %s job finished (%s): %s", kind, engine, self._state)
256
+
257
+ def _append(self, line: str) -> None:
258
+ with self._lock:
259
+ self._lines.append(line)
260
+ overflow = len(self._lines) - _MAX_LINES
261
+ if overflow > 0:
262
+ del self._lines[:overflow]
263
+ self._base += overflow
264
+
265
+
266
+ _manager = BackgroundJobManager()
267
+
268
+
269
+ def get_background_job_manager() -> BackgroundJobManager:
270
+ return _manager
271
+
272
+
273
+ __all__ = [
274
+ "ENGINE_MODEL_DOWNLOADERS",
275
+ "ENGINE_PIP_SPECS",
276
+ "BackgroundJobManager",
277
+ "get_background_job_manager",
278
+ "installable_engines",
279
+ "model_downloadable_engines",
280
+ "resolve_model_downloader",
281
+ ]
@@ -0,0 +1,38 @@
1
+ """Cheap, cached package-version lookup for parser signatures.
2
+
3
+ Using the installed distribution version (not a CLI ``--version`` subprocess)
4
+ keeps signature computation cheap while still invalidating the parse cache when
5
+ an engine is upgraded.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from functools import lru_cache
11
+ import importlib.metadata
12
+ import re
13
+
14
+
15
+ @lru_cache(maxsize=None)
16
+ def package_version(name: str) -> str:
17
+ try:
18
+ return importlib.metadata.version(name)
19
+ except Exception:
20
+ return ""
21
+
22
+
23
+ def version_at_least(version: str | None, minimum: str) -> bool:
24
+ """Compare numeric release components without importing packaging tooling."""
25
+
26
+ def release(value: str | None) -> tuple[int, ...]:
27
+ match = re.match(r"\d+(?:\.\d+)*", str(value or "").strip())
28
+ return tuple(int(part) for part in match.group(0).split(".")) if match else ()
29
+
30
+ current = release(version)
31
+ required = release(minimum)
32
+ width = max(len(current), len(required))
33
+ return bool(current and required) and current + (0,) * (width - len(current)) >= required + (
34
+ 0,
35
+ ) * (width - len(required))
36
+
37
+
38
+ __all__ = ["package_version", "version_at_least"]
@@ -0,0 +1,6 @@
1
+ """Docling engine — structured document conversion (downloads local models).
2
+
3
+ Converts every input format advertised by the supported Docling release into
4
+ Markdown. Downloads layout/table models on first run, so it shares the same
5
+ ``allow_local_model_download`` gate as MinerU local.
6
+ """