parsearena 0.1.3__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.
- parsearena/__init__.py +3 -0
- parsearena/cli.py +288 -0
- parsearena/demo/pdfs/NOTICE.md +36 -0
- parsearena/demo/pdfs/census-income-2023.pdf +0 -0
- parsearena/demo/pdfs/dpo-arxiv-2305.18290.pdf +0 -0
- parsearena/demo/pdfs/naca-report-1135-scan.pdf +0 -0
- parsearena/demo/questions.yaml +104 -0
- parsearena/demo/recorded.json +304 -0
- parsearena/errors.py +14 -0
- parsearena/evaluate.py +130 -0
- parsearena/llm.py +154 -0
- parsearena/parsers.py +197 -0
- parsearena/pipeline.py +276 -0
- parsearena/questions.py +125 -0
- parsearena/render.py +40 -0
- parsearena/report.py +57 -0
- parsearena/report_template.html +450 -0
- parsearena/retrieval.py +197 -0
- parsearena/runners/_common.py +148 -0
- parsearena/runners/run_docling.py +25 -0
- parsearena/runners/run_marker.py +54 -0
- parsearena/runners/run_mineru.py +59 -0
- parsearena/runners/run_pypdf.py +22 -0
- parsearena/runners/run_unstructured.py +38 -0
- parsearena/text.py +90 -0
- parsearena-0.1.3.dist-info/METADATA +267 -0
- parsearena-0.1.3.dist-info/RECORD +30 -0
- parsearena-0.1.3.dist-info/WHEEL +4 -0
- parsearena-0.1.3.dist-info/entry_points.txt +2 -0
- parsearena-0.1.3.dist-info/licenses/LICENSE +21 -0
parsearena/errors.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Errors that are shown to the user as a short message, never as a traceback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UserError(Exception):
|
|
7
|
+
def __init__(self, message: str, hint: str = "") -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.message = message
|
|
10
|
+
self.hint = hint
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BudgetExceeded(UserError):
|
|
14
|
+
pass
|
parsearena/evaluate.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Turn parser outputs plus a question set into retrieval metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .retrieval import BM25, Embedder, rrf, score_ranks
|
|
8
|
+
from .text import chunk_text, contains, normalize
|
|
9
|
+
|
|
10
|
+
MRR_CUTOFF = 10
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Question:
|
|
15
|
+
id: str
|
|
16
|
+
doc: str
|
|
17
|
+
q: str
|
|
18
|
+
answers: list[str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class DocOutput:
|
|
23
|
+
pages: list[str]
|
|
24
|
+
seconds: float = 0.0
|
|
25
|
+
error: str | None = None
|
|
26
|
+
device: str = ""
|
|
27
|
+
warmup_seconds: float | None = None
|
|
28
|
+
extra: dict = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def text(self) -> str:
|
|
32
|
+
return "\n\n".join(self.pages)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def ok(self) -> bool:
|
|
36
|
+
return self.error is None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _first_hit(ranks: dict[int, int], relevant: set[int]) -> int | None:
|
|
40
|
+
hits = [ranks[i] for i in relevant if i in ranks]
|
|
41
|
+
return min(hits) if hits else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def evaluate(
|
|
45
|
+
outputs: dict[str, dict[str, DocOutput]],
|
|
46
|
+
questions: list[Question],
|
|
47
|
+
k: int = 5,
|
|
48
|
+
embedder: Embedder | None = None,
|
|
49
|
+
chunk_chars: int = 1200,
|
|
50
|
+
progress=None,
|
|
51
|
+
) -> dict:
|
|
52
|
+
"""Score every parser on every question.
|
|
53
|
+
|
|
54
|
+
Retrieval runs over the chunks of *all* documents produced by one parser, so
|
|
55
|
+
a question can be answered from the wrong document. A chunk is relevant when
|
|
56
|
+
it belongs to the question's document and contains one of the answers.
|
|
57
|
+
"""
|
|
58
|
+
methods = ["bm25"] + (["dense", "hybrid"] if embedder is not None else [])
|
|
59
|
+
q_vecs = embedder.embed_queries([q.q for q in questions]) if (embedder and questions) else None
|
|
60
|
+
result: dict[str, dict] = {}
|
|
61
|
+
for parser, docs in outputs.items():
|
|
62
|
+
if progress:
|
|
63
|
+
progress(parser)
|
|
64
|
+
chunks = []
|
|
65
|
+
for doc_id, out in docs.items():
|
|
66
|
+
if out.ok:
|
|
67
|
+
chunks.extend(chunk_text(doc_id, out.text, chunk_chars))
|
|
68
|
+
texts = [c.text for c in chunks]
|
|
69
|
+
norm_chunks = [normalize(t) for t in texts]
|
|
70
|
+
norm_docs = {d: normalize(o.text) for d, o in docs.items() if o.ok}
|
|
71
|
+
bm25 = BM25(texts) if texts else None
|
|
72
|
+
c_vecs = embedder.embed(texts) if (embedder and texts) else None
|
|
73
|
+
per_q = []
|
|
74
|
+
for qi, q in enumerate(questions):
|
|
75
|
+
survives = any(contains(norm_docs.get(q.doc, ""), a) for a in q.answers)
|
|
76
|
+
relevant = {
|
|
77
|
+
i
|
|
78
|
+
for i, c in enumerate(chunks)
|
|
79
|
+
if c.doc == q.doc and any(contains(norm_chunks[i], a) for a in q.answers)
|
|
80
|
+
}
|
|
81
|
+
ranks: dict[str, int | None] = {}
|
|
82
|
+
if texts:
|
|
83
|
+
orders = {"bm25": score_ranks(bm25.scores(q.q), drop_zero=True)}
|
|
84
|
+
if c_vecs is not None and q_vecs is not None:
|
|
85
|
+
orders["dense"] = score_ranks((c_vecs @ q_vecs[qi]).tolist())
|
|
86
|
+
fused = rrf([orders["bm25"], orders["dense"]])
|
|
87
|
+
orders["hybrid"] = score_ranks([fused.get(i, 0.0) for i in range(len(texts))])
|
|
88
|
+
for m in methods:
|
|
89
|
+
ranks[m] = _first_hit(orders[m], relevant)
|
|
90
|
+
else:
|
|
91
|
+
ranks = {m: None for m in methods}
|
|
92
|
+
per_q.append({"id": q.id, "doc": q.doc, "survives": survives, "ranks": ranks})
|
|
93
|
+
summary = {"survives": _mean([p["survives"] for p in per_q]), "chunks": len(chunks)}
|
|
94
|
+
for m in methods:
|
|
95
|
+
rs = [p["ranks"][m] for p in per_q]
|
|
96
|
+
summary[m] = {
|
|
97
|
+
"recall": _mean([r is not None and r <= k for r in rs]),
|
|
98
|
+
"mrr": _mean([(1.0 / r) if (r is not None and r <= MRR_CUTOFF) else 0.0 for r in rs]),
|
|
99
|
+
}
|
|
100
|
+
result[parser] = {"summary": summary, "questions": per_q}
|
|
101
|
+
return {
|
|
102
|
+
"k": k,
|
|
103
|
+
"mrr_cutoff": MRR_CUTOFF,
|
|
104
|
+
"methods": methods,
|
|
105
|
+
"chunk_chars": chunk_chars,
|
|
106
|
+
"parsers": result,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def timings(outputs: dict[str, dict[str, DocOutput]], pages: dict[str, int]) -> dict[str, dict]:
|
|
111
|
+
out = {}
|
|
112
|
+
for parser, docs in outputs.items():
|
|
113
|
+
ok = [(d, o) for d, o in docs.items() if o.ok]
|
|
114
|
+
n_pages = sum(pages.get(d, len(o.pages)) for d, o in ok)
|
|
115
|
+
secs = sum(o.seconds for _, o in ok)
|
|
116
|
+
warm = next((o.warmup_seconds for o in docs.values() if o.warmup_seconds is not None), None)
|
|
117
|
+
out[parser] = {
|
|
118
|
+
"pages": n_pages,
|
|
119
|
+
"seconds": secs,
|
|
120
|
+
"seconds_per_page": (secs / n_pages) if n_pages else None,
|
|
121
|
+
"warmup_seconds": warm,
|
|
122
|
+
"failed": [{"doc": d, "error": o.error} for d, o in docs.items() if not o.ok],
|
|
123
|
+
"device": next((o.device for o in docs.values() if o.device), ""),
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _mean(values) -> float:
|
|
129
|
+
values = list(values)
|
|
130
|
+
return (sum(float(v) for v in values) / len(values)) if values else 0.0
|
parsearena/llm.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""One OpenAI-compatible client for every provider, with a hard spending cap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from .errors import BudgetExceeded, UserError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Preset:
|
|
14
|
+
base_url: str
|
|
15
|
+
key_env: str | None
|
|
16
|
+
model: str
|
|
17
|
+
price_in: float # USD per million input tokens (estimate used for the pre-call check)
|
|
18
|
+
price_out: float # USD per million output tokens
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
PRESETS: dict[str, Preset] = {
|
|
22
|
+
"ollama": Preset("http://localhost:11434/v1", None, "qwen3:4b", 0.0, 0.0),
|
|
23
|
+
"openrouter": Preset(
|
|
24
|
+
"https://openrouter.ai/api/v1", "OPENROUTER_API_KEY", "deepseek/deepseek-v4.1-flash", 0.045, 0.50
|
|
25
|
+
),
|
|
26
|
+
"openai": Preset("https://api.openai.com/v1", "OPENAI_API_KEY", "gpt-5.6-luna", 0.20, 1.20),
|
|
27
|
+
"mistral": Preset("https://api.mistral.ai/v1", "MISTRAL_API_KEY", "ministral-8b-latest", 0.10, 0.10),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ollama_base_url() -> str:
|
|
32
|
+
host = os.environ.get("OLLAMA_HOST", "").strip()
|
|
33
|
+
if not host:
|
|
34
|
+
return PRESETS["ollama"].base_url
|
|
35
|
+
if not host.startswith(("http://", "https://")):
|
|
36
|
+
host = f"http://{host}"
|
|
37
|
+
return host.rstrip("/") + "/v1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class LLM:
|
|
42
|
+
provider: str
|
|
43
|
+
model: str
|
|
44
|
+
max_cost: float = 1.0
|
|
45
|
+
price_in: float | None = None
|
|
46
|
+
price_out: float | None = None
|
|
47
|
+
spent: float = 0.0
|
|
48
|
+
calls: int = 0
|
|
49
|
+
_client: object = field(default=None, repr=False)
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
if self.provider not in PRESETS:
|
|
53
|
+
raise UserError(f"Unknown provider: {self.provider}", f"Choose one of: {', '.join(PRESETS)}")
|
|
54
|
+
preset = PRESETS[self.provider]
|
|
55
|
+
self.model = self.model or preset.model
|
|
56
|
+
if self.price_in is None:
|
|
57
|
+
self.price_in = preset.price_in
|
|
58
|
+
if self.price_out is None:
|
|
59
|
+
self.price_out = preset.price_out
|
|
60
|
+
if preset.key_env:
|
|
61
|
+
key = os.environ.get(preset.key_env, "").strip()
|
|
62
|
+
if not key:
|
|
63
|
+
raise UserError(
|
|
64
|
+
f"Missing API key: set {preset.key_env}.",
|
|
65
|
+
f"Example: export {preset.key_env}=... or use --provider ollama for a free local model.",
|
|
66
|
+
)
|
|
67
|
+
base_url = preset.base_url
|
|
68
|
+
else:
|
|
69
|
+
key, base_url = "ollama", ollama_base_url()
|
|
70
|
+
from openai import OpenAI
|
|
71
|
+
|
|
72
|
+
self.base_url = base_url
|
|
73
|
+
self.timeout = float(os.environ.get("PARSEARENA_LLM_TIMEOUT", "300"))
|
|
74
|
+
self._client = OpenAI(base_url=base_url, api_key=key, max_retries=0, timeout=self.timeout)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def local(self) -> bool:
|
|
78
|
+
return self.provider == "ollama"
|
|
79
|
+
|
|
80
|
+
def estimate(self, prompt: str, max_tokens: int) -> float:
|
|
81
|
+
tokens_in = len(prompt) / 3.5
|
|
82
|
+
return (tokens_in * self.price_in + max_tokens * self.price_out) / 1e6
|
|
83
|
+
|
|
84
|
+
def complete(self, system: str, prompt: str, max_tokens: int = 2000) -> str:
|
|
85
|
+
if not self.local:
|
|
86
|
+
projected = self.spent + self.estimate(system + prompt, max_tokens)
|
|
87
|
+
if projected > self.max_cost:
|
|
88
|
+
raise BudgetExceeded(
|
|
89
|
+
f"Budget exceeded: the next call could bring spend to ${projected:.4f}, "
|
|
90
|
+
f"over --max-cost ${self.max_cost:.2f}.",
|
|
91
|
+
"Partial results were kept. Raise --max-cost or use --provider ollama.",
|
|
92
|
+
)
|
|
93
|
+
extra = {"usage": {"include": True}} if self.provider == "openrouter" else None
|
|
94
|
+
resp = self._call(system, prompt, max_tokens, extra)
|
|
95
|
+
self.calls += 1
|
|
96
|
+
self.spent += self._cost(resp)
|
|
97
|
+
return (resp.choices[0].message.content or "").strip()
|
|
98
|
+
|
|
99
|
+
def _call(self, system, prompt, max_tokens, extra):
|
|
100
|
+
import openai
|
|
101
|
+
|
|
102
|
+
delay = 2.0
|
|
103
|
+
for attempt in range(5):
|
|
104
|
+
try:
|
|
105
|
+
return self._client.chat.completions.create(
|
|
106
|
+
model=self.model,
|
|
107
|
+
messages=[{"role": "system", "content": system}, {"role": "user", "content": prompt}],
|
|
108
|
+
max_tokens=max_tokens,
|
|
109
|
+
temperature=0.2,
|
|
110
|
+
extra_body=extra,
|
|
111
|
+
)
|
|
112
|
+
except openai.APITimeoutError as exc:
|
|
113
|
+
if self.local or attempt == 4:
|
|
114
|
+
raise UserError(
|
|
115
|
+
f"{self.provider} did not answer within {self.timeout:.0f}s.",
|
|
116
|
+
"The server may be busy or still loading the model. Try again, "
|
|
117
|
+
"or raise the limit with PARSEARENA_LLM_TIMEOUT (seconds).",
|
|
118
|
+
) from exc
|
|
119
|
+
except openai.APIConnectionError as exc:
|
|
120
|
+
if self.local:
|
|
121
|
+
raise UserError(
|
|
122
|
+
f"Ollama is not reachable at {self.base_url}.",
|
|
123
|
+
"Start it with `ollama serve`, or set OLLAMA_HOST.",
|
|
124
|
+
) from exc
|
|
125
|
+
if attempt == 4:
|
|
126
|
+
raise UserError(f"Could not reach {self.provider}: {exc}") from exc
|
|
127
|
+
except openai.AuthenticationError as exc:
|
|
128
|
+
raise UserError(
|
|
129
|
+
f"{self.provider} rejected the API key.", f"Check {PRESETS[self.provider].key_env}."
|
|
130
|
+
) from exc
|
|
131
|
+
except openai.NotFoundError as exc:
|
|
132
|
+
hint = f"Pull it with `ollama pull {self.model}`." if self.local else "Check --model."
|
|
133
|
+
raise UserError(f"Model not found on {self.provider}: {self.model}", hint) from exc
|
|
134
|
+
except (openai.RateLimitError, openai.InternalServerError) as exc:
|
|
135
|
+
if attempt == 4:
|
|
136
|
+
raise UserError(f"{self.provider} keeps failing: {exc}", "Try again later.") from exc
|
|
137
|
+
time.sleep(delay)
|
|
138
|
+
delay *= 2
|
|
139
|
+
raise UserError(f"{self.provider} did not answer.")
|
|
140
|
+
|
|
141
|
+
def _cost(self, resp) -> float:
|
|
142
|
+
if self.local:
|
|
143
|
+
return 0.0
|
|
144
|
+
usage = getattr(resp, "usage", None)
|
|
145
|
+
if usage is None:
|
|
146
|
+
return 0.0
|
|
147
|
+
reported = getattr(usage, "cost", None)
|
|
148
|
+
if reported is None and getattr(usage, "model_extra", None):
|
|
149
|
+
reported = usage.model_extra.get("cost")
|
|
150
|
+
if reported is not None:
|
|
151
|
+
return float(reported)
|
|
152
|
+
return (
|
|
153
|
+
(usage.prompt_tokens or 0) * self.price_in + (usage.completion_tokens or 0) * self.price_out
|
|
154
|
+
) / 1e6
|
parsearena/parsers.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Parser registry and isolated environments.
|
|
2
|
+
|
|
3
|
+
Every parser lives in its own uv-managed virtual environment under
|
|
4
|
+
``$PARSEARENA_HOME/envs`` and is driven through a small runner script in a
|
|
5
|
+
subprocess. parsearena never imports a parser and never bundles one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from importlib import resources
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .errors import UserError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ParserSpec:
|
|
25
|
+
name: str
|
|
26
|
+
label: str
|
|
27
|
+
packages: tuple[str, ...]
|
|
28
|
+
runner: str
|
|
29
|
+
license: str
|
|
30
|
+
url: str
|
|
31
|
+
python: str = "3.12"
|
|
32
|
+
env: tuple[tuple[str, str], ...] = ()
|
|
33
|
+
# (binary, env var that may point to it, install hint) checked before running
|
|
34
|
+
tools: tuple[tuple[str, str, str], ...] = ()
|
|
35
|
+
note: str = ""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def version(self) -> str:
|
|
39
|
+
return self.packages[0].split("==", 1)[1]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def key(self) -> str:
|
|
43
|
+
digest = hashlib.sha256(json.dumps([self.packages, self.python]).encode()).hexdigest()[:8]
|
|
44
|
+
return f"{self.name}-{self.version}-py{self.python.replace('.', '')}-{digest}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
PARSERS: dict[str, ParserSpec] = {
|
|
48
|
+
"docling": ParserSpec(
|
|
49
|
+
name="docling",
|
|
50
|
+
label="Docling",
|
|
51
|
+
packages=("docling==2.146.0",),
|
|
52
|
+
runner="run_docling.py",
|
|
53
|
+
license="MIT",
|
|
54
|
+
url="https://github.com/docling-project/docling",
|
|
55
|
+
),
|
|
56
|
+
"marker": ParserSpec(
|
|
57
|
+
name="marker",
|
|
58
|
+
label="Marker",
|
|
59
|
+
packages=("marker-pdf==2.1.0",),
|
|
60
|
+
runner="run_marker.py",
|
|
61
|
+
license="Code Apache-2.0; model weights modified OpenRAIL-M",
|
|
62
|
+
url="https://github.com/datalab-to/marker",
|
|
63
|
+
tools=(
|
|
64
|
+
(
|
|
65
|
+
"llama-server",
|
|
66
|
+
"LLAMA_CPP_BINARY",
|
|
67
|
+
"Marker 2.x runs its OCR model through llama.cpp when there is no NVIDIA GPU: "
|
|
68
|
+
"`brew install llama.cpp`, or set LLAMA_CPP_BINARY to a llama-server binary.",
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
"mineru": ParserSpec(
|
|
73
|
+
name="mineru",
|
|
74
|
+
label="MinerU",
|
|
75
|
+
packages=("mineru==4.1.6",),
|
|
76
|
+
runner="run_mineru.py",
|
|
77
|
+
license="Apache-2.0 with additional terms",
|
|
78
|
+
url="https://github.com/opendatalab/MinerU",
|
|
79
|
+
env=(("MINERU_MODEL_SOURCE", "huggingface"), ("MINERU_TELEMETRY_DISABLED", "1")),
|
|
80
|
+
note='effort "medium": local layout, OCR, table and formula models, no VLM',
|
|
81
|
+
),
|
|
82
|
+
"unstructured": ParserSpec(
|
|
83
|
+
name="unstructured",
|
|
84
|
+
label="Unstructured",
|
|
85
|
+
packages=("unstructured[pdf]==0.27.36",),
|
|
86
|
+
runner="run_unstructured.py",
|
|
87
|
+
license="Apache-2.0",
|
|
88
|
+
url="https://github.com/Unstructured-IO/unstructured",
|
|
89
|
+
tools=(
|
|
90
|
+
(
|
|
91
|
+
"pdftoppm",
|
|
92
|
+
"",
|
|
93
|
+
"Unstructured hi_res needs poppler: `brew install poppler` / `apt install poppler-utils`.",
|
|
94
|
+
),
|
|
95
|
+
(
|
|
96
|
+
"tesseract",
|
|
97
|
+
"",
|
|
98
|
+
"Unstructured hi_res needs tesseract: "
|
|
99
|
+
"`brew install tesseract` / `apt install tesseract-ocr`.",
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
"pypdf": ParserSpec(
|
|
104
|
+
name="pypdf",
|
|
105
|
+
label="pypdf (baseline)",
|
|
106
|
+
packages=("pypdf==6.21.4",),
|
|
107
|
+
runner="run_pypdf.py",
|
|
108
|
+
license="BSD-3-Clause",
|
|
109
|
+
url="https://github.com/py-pdf/pypdf",
|
|
110
|
+
),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
DEFAULT_PARSERS = ("docling", "marker", "mineru", "unstructured", "pypdf")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_parser_list(value: str) -> list[ParserSpec]:
|
|
117
|
+
names = [n.strip().lower() for n in value.split(",") if n.strip()]
|
|
118
|
+
if not names:
|
|
119
|
+
raise UserError("No parsers selected.", f"Choose from: {', '.join(PARSERS)}")
|
|
120
|
+
unknown = [n for n in names if n not in PARSERS]
|
|
121
|
+
if unknown:
|
|
122
|
+
raise UserError(
|
|
123
|
+
f"Unknown parser: {', '.join(unknown)}",
|
|
124
|
+
f"Available parsers: {', '.join(PARSERS)}",
|
|
125
|
+
)
|
|
126
|
+
return [PARSERS[n] for n in dict.fromkeys(names)]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def home() -> Path:
|
|
130
|
+
if os.environ.get("PARSEARENA_HOME"):
|
|
131
|
+
return Path(os.environ["PARSEARENA_HOME"]).expanduser()
|
|
132
|
+
base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
|
|
133
|
+
return Path(base) / "parsearena"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def env_dir(spec: ParserSpec) -> Path:
|
|
137
|
+
return home() / "envs" / spec.key
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def env_python(spec: ParserSpec) -> Path:
|
|
141
|
+
d = env_dir(spec)
|
|
142
|
+
return d / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def missing_tools(spec: ParserSpec) -> list[str]:
|
|
146
|
+
"""Install hints for system tools the parser needs but that are not available."""
|
|
147
|
+
hints = []
|
|
148
|
+
for binary, env_var, hint in spec.tools:
|
|
149
|
+
configured = os.environ.get(env_var) if env_var else None
|
|
150
|
+
if configured and Path(configured).exists():
|
|
151
|
+
continue
|
|
152
|
+
if shutil.which(binary) is None:
|
|
153
|
+
hints.append(hint)
|
|
154
|
+
return hints
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def find_uv() -> str:
|
|
158
|
+
uv = shutil.which("uv")
|
|
159
|
+
if uv is None:
|
|
160
|
+
raise UserError(
|
|
161
|
+
"uv was not found on PATH.",
|
|
162
|
+
"parsearena installs each parser in its own uv environment. "
|
|
163
|
+
"Install uv: https://docs.astral.sh/uv/getting-started/installation/",
|
|
164
|
+
)
|
|
165
|
+
return uv
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def is_installed(spec: ParserSpec) -> bool:
|
|
169
|
+
return (env_dir(spec) / ".parsearena-ok").exists() and env_python(spec).exists()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def ensure_env(spec: ParserSpec, log=print) -> Path:
|
|
173
|
+
"""Create the parser's environment if needed and return its python."""
|
|
174
|
+
if is_installed(spec):
|
|
175
|
+
return env_python(spec)
|
|
176
|
+
uv = find_uv()
|
|
177
|
+
d = env_dir(spec)
|
|
178
|
+
d.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
log(f"Installing {spec.label} ({', '.join(spec.packages)}) into {d}")
|
|
180
|
+
steps = [
|
|
181
|
+
[uv, "venv", "--quiet", "--clear", "--python", spec.python, str(d)],
|
|
182
|
+
[uv, "pip", "install", "--quiet", "--python", str(env_python(spec)), *spec.packages],
|
|
183
|
+
]
|
|
184
|
+
for cmd in steps:
|
|
185
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
186
|
+
if proc.returncode != 0:
|
|
187
|
+
tail = (proc.stderr or proc.stdout).strip().splitlines()[-15:]
|
|
188
|
+
raise UserError(
|
|
189
|
+
f"Could not install {spec.label}.",
|
|
190
|
+
"uv said:\n" + "\n".join(tail),
|
|
191
|
+
)
|
|
192
|
+
(d / ".parsearena-ok").write_text(json.dumps({"packages": spec.packages}))
|
|
193
|
+
return env_python(spec)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def runner_dir() -> Path:
|
|
197
|
+
return Path(str(resources.files("parsearena") / "runners"))
|