lorebase-scribe 0.2.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.
- lorebase_scribe/__init__.py +3 -0
- lorebase_scribe/__main__.py +156 -0
- lorebase_scribe/checkpoints.py +324 -0
- lorebase_scribe/cron.py +117 -0
- lorebase_scribe/embeddings.py +570 -0
- lorebase_scribe/extraction/__init__.py +17 -0
- lorebase_scribe/extraction/assemble.py +339 -0
- lorebase_scribe/extraction/candidates.py +136 -0
- lorebase_scribe/extraction/discovery.py +567 -0
- lorebase_scribe/extraction/llm_discovery.py +271 -0
- lorebase_scribe/extraction/matching.py +285 -0
- lorebase_scribe/extraction/ontology.py +432 -0
- lorebase_scribe/extraction/parsing.py +114 -0
- lorebase_scribe/extraction/pipeline.py +1091 -0
- lorebase_scribe/extraction/relations.py +194 -0
- lorebase_scribe/extraction/sandbox.py +269 -0
- lorebase_scribe/extraction/settings.py +293 -0
- lorebase_scribe/extraction/text.py +179 -0
- lorebase_scribe/extraction/tier2.py +573 -0
- lorebase_scribe/extraction/values.py +282 -0
- lorebase_scribe/jobs.py +418 -0
- lorebase_scribe/scheduler.py +318 -0
- lorebase_scribe/service.py +146 -0
- lorebase_scribe/settings.py +521 -0
- lorebase_scribe/sources/__init__.py +60 -0
- lorebase_scribe/sources/access.py +241 -0
- lorebase_scribe/sources/connectors.py +320 -0
- lorebase_scribe/sources/delta.py +175 -0
- lorebase_scribe/sources/http_json.py +283 -0
- lorebase_scribe/sources/mapping.py +429 -0
- lorebase_scribe/sources/profile.py +36 -0
- lorebase_scribe/sources/records.py +298 -0
- lorebase_scribe/sources/sql.py +332 -0
- lorebase_scribe/sources/sync.py +730 -0
- lorebase_scribe/telemetry.py +113 -0
- lorebase_scribe/webhooks.py +99 -0
- lorebase_scribe/worker.py +254 -0
- lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
- lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
- lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
- lorebase_scribe-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Link candidates between resolved mentions.
|
|
2
|
+
|
|
3
|
+
- Co-occurrence (tier 0): two Records named in one sentence (a table row is a sentence) are
|
|
4
|
+
linked by every Relation whose endpoint Kinds they have; confidence decreases with the words
|
|
5
|
+
between them and never exceeds `confidence.cooccurrence_max`.
|
|
6
|
+
- Headings (tier 0): the Record a heading names is linked to the Records named in its section
|
|
7
|
+
(up to the next heading of the same or a higher level), at `confidence.heading`.
|
|
8
|
+
- Model (tier 1): a `RelationExtractor` of the inference package (GLiREL), with Relation api
|
|
9
|
+
names as labels constrained to their endpoint Kinds.
|
|
10
|
+
|
|
11
|
+
Co-occurrence knows nothing about meaning; its candidates are a superset that evidence, review
|
|
12
|
+
and the confidence threshold sort out.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
|
|
21
|
+
from lorebase_inference.interfaces import RelationExtractor, RelationLabel, Span
|
|
22
|
+
from lorebase_scribe.extraction.candidates import Evidence, LinkCandidate, Resolved
|
|
23
|
+
from lorebase_scribe.extraction.ontology import Ontology
|
|
24
|
+
from lorebase_scribe.extraction.settings import ExtractionSettings
|
|
25
|
+
from lorebase_scribe.extraction.text import heading_sections
|
|
26
|
+
|
|
27
|
+
_WORD = re.compile(r"\w+")
|
|
28
|
+
COOCCURRENCE = "cooccurrence"
|
|
29
|
+
HEADING = "heading"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pairs(
|
|
33
|
+
ontology: Ontology, first: Resolved, second: Resolved
|
|
34
|
+
) -> list[tuple[str, Resolved, Resolved]]:
|
|
35
|
+
"""`(relation id, from, to)` for every Relation the two mentions can stand in."""
|
|
36
|
+
if (first.kind_id, first.key) == (second.kind_id, second.key):
|
|
37
|
+
return []
|
|
38
|
+
found: list[tuple[str, Resolved, Resolved]] = []
|
|
39
|
+
for relation in ontology.relations.values():
|
|
40
|
+
if relation.from_kind == first.kind_id and relation.to_kind == second.kind_id:
|
|
41
|
+
found.append((relation.id, first, second))
|
|
42
|
+
elif relation.from_kind == second.kind_id and relation.to_kind == first.kind_id:
|
|
43
|
+
found.append((relation.id, second, first))
|
|
44
|
+
return found
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cooccurrence_links(
|
|
48
|
+
ontology: Ontology,
|
|
49
|
+
resolved: Sequence[Resolved],
|
|
50
|
+
text: str,
|
|
51
|
+
sentences: Sequence[tuple[int, int]],
|
|
52
|
+
*,
|
|
53
|
+
document_id: str,
|
|
54
|
+
settings: ExtractionSettings,
|
|
55
|
+
) -> list[LinkCandidate]:
|
|
56
|
+
by_sentence: dict[tuple[int, int], list[Resolved]] = defaultdict(list)
|
|
57
|
+
for item in resolved:
|
|
58
|
+
for bound in sentences:
|
|
59
|
+
if bound[0] <= item.mention.start < bound[1]:
|
|
60
|
+
by_sentence[bound].append(item)
|
|
61
|
+
break
|
|
62
|
+
found: list[LinkCandidate] = []
|
|
63
|
+
ceiling = settings.confidence.cooccurrence_max
|
|
64
|
+
for (start, end), group in sorted(by_sentence.items()):
|
|
65
|
+
ordered = sorted(group, key=lambda r: r.mention.start)
|
|
66
|
+
for index, first in enumerate(ordered):
|
|
67
|
+
for second in ordered[index + 1 :]:
|
|
68
|
+
gap = len(_WORD.findall(text, first.mention.end, max(second.mention.start, 0)))
|
|
69
|
+
proximity = 1 / (1 + gap / 10)
|
|
70
|
+
confidence = ceiling * min(first.confidence, second.confidence) * proximity
|
|
71
|
+
for relation_id, source, target in _pairs(ontology, first, second):
|
|
72
|
+
found.append(
|
|
73
|
+
LinkCandidate(
|
|
74
|
+
relation_id=relation_id,
|
|
75
|
+
source=source,
|
|
76
|
+
target=target,
|
|
77
|
+
evidence=Evidence(
|
|
78
|
+
document_id, first.mention.chunk, start, end, text[start:end]
|
|
79
|
+
),
|
|
80
|
+
confidence=confidence,
|
|
81
|
+
tier=0,
|
|
82
|
+
model=COOCCURRENCE,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return found
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def heading_links(
|
|
89
|
+
ontology: Ontology,
|
|
90
|
+
resolved: Sequence[Resolved],
|
|
91
|
+
text: str,
|
|
92
|
+
sentences: Sequence[tuple[int, int]],
|
|
93
|
+
*,
|
|
94
|
+
document_id: str,
|
|
95
|
+
settings: ExtractionSettings,
|
|
96
|
+
) -> list[LinkCandidate]:
|
|
97
|
+
found: list[LinkCandidate] = []
|
|
98
|
+
for section in heading_sections(text):
|
|
99
|
+
start, line_end, end = section.start, section.heading_end, section.end
|
|
100
|
+
heads = [r for r in resolved if start <= r.mention.start < line_end]
|
|
101
|
+
body = [r for r in resolved if line_end <= r.mention.start < end]
|
|
102
|
+
for head in heads:
|
|
103
|
+
for other in body:
|
|
104
|
+
sentence = next(
|
|
105
|
+
((s, e) for s, e in sentences if s <= other.mention.start < e),
|
|
106
|
+
(other.mention.start, other.mention.end),
|
|
107
|
+
)
|
|
108
|
+
confidence = settings.confidence.heading * min(head.confidence, other.confidence)
|
|
109
|
+
for relation_id, source, target in _pairs(ontology, head, other):
|
|
110
|
+
found.append(
|
|
111
|
+
LinkCandidate(
|
|
112
|
+
relation_id=relation_id,
|
|
113
|
+
source=source,
|
|
114
|
+
target=target,
|
|
115
|
+
evidence=Evidence(
|
|
116
|
+
document_id,
|
|
117
|
+
other.mention.chunk,
|
|
118
|
+
sentence[0],
|
|
119
|
+
sentence[1],
|
|
120
|
+
text[sentence[0] : sentence[1]],
|
|
121
|
+
),
|
|
122
|
+
confidence=confidence,
|
|
123
|
+
tier=0,
|
|
124
|
+
model=HEADING,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return found
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def model_links(
|
|
131
|
+
extractor: RelationExtractor,
|
|
132
|
+
ontology: Ontology,
|
|
133
|
+
resolved: Sequence[Resolved],
|
|
134
|
+
chunk_text: str,
|
|
135
|
+
chunk_start: int,
|
|
136
|
+
*,
|
|
137
|
+
document_id: str,
|
|
138
|
+
model: str,
|
|
139
|
+
) -> list[LinkCandidate]:
|
|
140
|
+
"""Relations a local model finds between the mentions of one chunk."""
|
|
141
|
+
inside = [
|
|
142
|
+
r
|
|
143
|
+
for r in resolved
|
|
144
|
+
if chunk_start <= r.mention.start and r.mention.end <= chunk_start + len(chunk_text)
|
|
145
|
+
]
|
|
146
|
+
kinds = {r.kind_id for r in inside}
|
|
147
|
+
relations = ontology.relations_between(kinds)
|
|
148
|
+
if len(inside) < 2 or not relations:
|
|
149
|
+
return []
|
|
150
|
+
by_offsets: dict[tuple[int, int], Resolved] = {}
|
|
151
|
+
spans: list[Span] = []
|
|
152
|
+
for item in inside:
|
|
153
|
+
start, end = item.mention.start - chunk_start, item.mention.end - chunk_start
|
|
154
|
+
by_offsets[(start, end)] = item
|
|
155
|
+
spans.append(Span(ontology.kinds[item.kind_id].api_name, chunk_text[start:end], start, end))
|
|
156
|
+
labels = [
|
|
157
|
+
RelationLabel(
|
|
158
|
+
name=r.api_name,
|
|
159
|
+
description=r.description,
|
|
160
|
+
head_labels=(ontology.kinds[r.from_kind].api_name,),
|
|
161
|
+
tail_labels=(ontology.kinds[r.to_kind].api_name,),
|
|
162
|
+
)
|
|
163
|
+
for r in relations
|
|
164
|
+
]
|
|
165
|
+
by_name = {r.api_name: r for r in relations}
|
|
166
|
+
found: list[LinkCandidate] = []
|
|
167
|
+
for relation in await extractor.extract(chunk_text, spans, labels):
|
|
168
|
+
spec = by_name.get(relation.label)
|
|
169
|
+
source = by_offsets.get((relation.head.start, relation.head.end))
|
|
170
|
+
target = by_offsets.get((relation.tail.start, relation.tail.end))
|
|
171
|
+
if spec is None or source is None or target is None:
|
|
172
|
+
continue
|
|
173
|
+
if (source.kind_id, target.kind_id) != (spec.from_kind, spec.to_kind):
|
|
174
|
+
continue
|
|
175
|
+
lo = min(relation.head.start, relation.tail.start)
|
|
176
|
+
hi = max(relation.head.end, relation.tail.end)
|
|
177
|
+
found.append(
|
|
178
|
+
LinkCandidate(
|
|
179
|
+
relation_id=spec.id,
|
|
180
|
+
source=source,
|
|
181
|
+
target=target,
|
|
182
|
+
evidence=Evidence(
|
|
183
|
+
document_id,
|
|
184
|
+
source.mention.chunk,
|
|
185
|
+
chunk_start + lo,
|
|
186
|
+
chunk_start + hi,
|
|
187
|
+
chunk_text[lo:hi],
|
|
188
|
+
),
|
|
189
|
+
confidence=relation.score * min(source.confidence, target.confidence),
|
|
190
|
+
tier=1,
|
|
191
|
+
model=model,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
return found
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""PDF and DOCX parsing in a limited child process (`scribe.extraction.parser`).
|
|
2
|
+
|
|
3
|
+
pypdf and python-docx run on bytes uploaded by Domain editors; a crafted file can make them loop
|
|
4
|
+
or allocate without bound. `parse_isolated` therefore hands the bytes to a child Python process
|
|
5
|
+
(`python -I -B -m lorebase_scribe.extraction.sandbox`) over standard input and reads a JSON
|
|
6
|
+
answer from standard output. The child, before it reads anything:
|
|
7
|
+
|
|
8
|
+
- lowers its own limits: CPU seconds (`RLIMIT_CPU`), address space (`RLIMIT_AS`, where the
|
|
9
|
+
system enforces it) and core files (none); `resource` exists on POSIX only, elsewhere only
|
|
10
|
+
the wall-clock limit applies;
|
|
11
|
+
- replaces the socket constructors and name resolution with functions that refuse, so a parser
|
|
12
|
+
cannot open a connection, and runs with an empty environment (no proxy variables);
|
|
13
|
+
- cuts the text at the character limit it is given, so the answer stays bounded.
|
|
14
|
+
|
|
15
|
+
The parent bounds the whole exchange by `timeout_s` and kills the child when it expires, reads
|
|
16
|
+
at most a fixed number of bytes from it, and turns every failure (limit, crash, garbled answer)
|
|
17
|
+
into a `ParseError`, which fails the extraction job without a retry.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import asyncio
|
|
24
|
+
import contextlib
|
|
25
|
+
import importlib
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import signal
|
|
29
|
+
import socket
|
|
30
|
+
import sys
|
|
31
|
+
from collections.abc import Callable, Sequence
|
|
32
|
+
from typing import Any, NoReturn
|
|
33
|
+
|
|
34
|
+
from lorebase_scribe.extraction.parsing import (
|
|
35
|
+
MARKDOWN,
|
|
36
|
+
PLAIN_TEXT,
|
|
37
|
+
ParsedDocument,
|
|
38
|
+
ParseError,
|
|
39
|
+
parse_document,
|
|
40
|
+
)
|
|
41
|
+
from lorebase_scribe.extraction.settings import ParserSettings
|
|
42
|
+
|
|
43
|
+
DEFAULT_TARGET = "lorebase_scribe.extraction.parsing:parse_document"
|
|
44
|
+
"""The function the child runs: `(data: bytes, media_type: str) -> ParsedDocument`."""
|
|
45
|
+
NATIVE = (MARKDOWN, PLAIN_TEXT)
|
|
46
|
+
"""Media types decoded in the worker: no third-party parser is involved."""
|
|
47
|
+
ANSWER_OVERHEAD_BYTES = 16 * 1024 * 1024
|
|
48
|
+
"""Room in the child's answer beyond the text: page offsets, warnings, JSON escapes."""
|
|
49
|
+
_READ = 64 * 1024
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _answer_limit(max_chars: int) -> int:
|
|
53
|
+
# JSON escapes a character into at most 12 bytes (a surrogate pair as two \\uXXXX).
|
|
54
|
+
return max_chars * 12 + ANSWER_OVERHEAD_BYTES
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def parse_isolated(
|
|
58
|
+
data: bytes,
|
|
59
|
+
media_type: str,
|
|
60
|
+
settings: ParserSettings,
|
|
61
|
+
*,
|
|
62
|
+
max_chars: int,
|
|
63
|
+
target: str = DEFAULT_TARGET,
|
|
64
|
+
python_path: Sequence[str] = (),
|
|
65
|
+
) -> ParsedDocument:
|
|
66
|
+
"""Text of a document, parsed under `settings`; raises `ParseError`.
|
|
67
|
+
|
|
68
|
+
`max_chars` bounds the text returned (one more character is kept, so the caller still sees
|
|
69
|
+
that the text was longer). `target` and `python_path` choose the function the child runs.
|
|
70
|
+
"""
|
|
71
|
+
if len(data) > settings.max_input_bytes:
|
|
72
|
+
raise ParseError(
|
|
73
|
+
f"the document has {len(data)} bytes, more than "
|
|
74
|
+
f"scribe.extraction.parser.max_input_bytes ({settings.max_input_bytes})"
|
|
75
|
+
)
|
|
76
|
+
if media_type in NATIVE or not settings.isolated:
|
|
77
|
+
return await asyncio.to_thread(parse_document, data, media_type)
|
|
78
|
+
command = [
|
|
79
|
+
sys.executable,
|
|
80
|
+
"-I",
|
|
81
|
+
"-B",
|
|
82
|
+
"-m",
|
|
83
|
+
__name__,
|
|
84
|
+
"--media-type",
|
|
85
|
+
media_type,
|
|
86
|
+
"--cpu-s",
|
|
87
|
+
str(settings.cpu_s),
|
|
88
|
+
"--memory-bytes",
|
|
89
|
+
str(settings.memory_mb * 1024 * 1024),
|
|
90
|
+
"--max-chars",
|
|
91
|
+
str(max_chars + 1),
|
|
92
|
+
"--target",
|
|
93
|
+
target,
|
|
94
|
+
]
|
|
95
|
+
for entry in python_path:
|
|
96
|
+
command += ["--path", entry]
|
|
97
|
+
process = await asyncio.create_subprocess_exec(
|
|
98
|
+
*command,
|
|
99
|
+
stdin=asyncio.subprocess.PIPE,
|
|
100
|
+
stdout=asyncio.subprocess.PIPE,
|
|
101
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
102
|
+
env=_child_environment(),
|
|
103
|
+
start_new_session=True,
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
async with asyncio.timeout(settings.timeout_s):
|
|
107
|
+
answer = await _exchange(process, data, _answer_limit(max_chars + 1))
|
|
108
|
+
except TimeoutError:
|
|
109
|
+
raise ParseError(
|
|
110
|
+
f"parsing took longer than scribe.extraction.parser.timeout_s ({settings.timeout_s} s)"
|
|
111
|
+
) from None
|
|
112
|
+
finally:
|
|
113
|
+
if process.returncode is None:
|
|
114
|
+
with contextlib.suppress(ProcessLookupError):
|
|
115
|
+
process.kill()
|
|
116
|
+
await process.wait()
|
|
117
|
+
return _decode_answer(answer, process.returncode, settings)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _child_environment() -> dict[str, str]:
|
|
121
|
+
"""Nothing from the worker's environment (no secrets, no proxies) but what Python needs."""
|
|
122
|
+
return {name: os.environ[name] for name in ("SYSTEMROOT",) if name in os.environ}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _exchange(process: asyncio.subprocess.Process, data: bytes, limit: int) -> bytes:
|
|
126
|
+
stdin, stdout = process.stdin, process.stdout
|
|
127
|
+
assert stdin is not None
|
|
128
|
+
assert stdout is not None
|
|
129
|
+
|
|
130
|
+
async def feed() -> None:
|
|
131
|
+
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
|
132
|
+
stdin.write(data)
|
|
133
|
+
await stdin.drain()
|
|
134
|
+
stdin.close()
|
|
135
|
+
|
|
136
|
+
feeding = asyncio.create_task(feed())
|
|
137
|
+
answer = bytearray()
|
|
138
|
+
try:
|
|
139
|
+
while block := await stdout.read(_READ):
|
|
140
|
+
answer.extend(block)
|
|
141
|
+
if len(answer) > limit:
|
|
142
|
+
raise ParseError("the parser answered more than its output limit")
|
|
143
|
+
await feeding
|
|
144
|
+
finally:
|
|
145
|
+
feeding.cancel()
|
|
146
|
+
await process.wait()
|
|
147
|
+
return bytes(answer)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _decode_answer(
|
|
151
|
+
answer: bytes, returncode: int | None, settings: ParserSettings
|
|
152
|
+
) -> ParsedDocument:
|
|
153
|
+
if returncode is not None and returncode < 0:
|
|
154
|
+
stopped = -returncode
|
|
155
|
+
if stopped == getattr(signal, "SIGXCPU", None):
|
|
156
|
+
raise ParseError(
|
|
157
|
+
f"parsing used more than scribe.extraction.parser.cpu_s ({settings.cpu_s} s) "
|
|
158
|
+
"of CPU time"
|
|
159
|
+
)
|
|
160
|
+
raise ParseError(
|
|
161
|
+
f"the parser process was stopped by signal {stopped} (memory limit "
|
|
162
|
+
f"scribe.extraction.parser.memory_mb is {settings.memory_mb} MiB)"
|
|
163
|
+
)
|
|
164
|
+
try:
|
|
165
|
+
found = json.loads(answer)
|
|
166
|
+
except ValueError:
|
|
167
|
+
raise ParseError(f"the parser process failed (exit status {returncode})") from None
|
|
168
|
+
if not isinstance(found, dict):
|
|
169
|
+
raise ParseError("the parser process answered something other than a document")
|
|
170
|
+
if "error" in found:
|
|
171
|
+
raise ParseError(str(found["error"]))
|
|
172
|
+
try:
|
|
173
|
+
return ParsedDocument(
|
|
174
|
+
text=str(found["text"]),
|
|
175
|
+
media_type=str(found["media_type"]),
|
|
176
|
+
pages=tuple(int(p) for p in found.get("pages", ())),
|
|
177
|
+
warnings=tuple(str(w) for w in found.get("warnings", ())),
|
|
178
|
+
)
|
|
179
|
+
except (KeyError, TypeError, ValueError):
|
|
180
|
+
raise ParseError("the parser process answered an incomplete document") from None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# The child --------------------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _refuse(*_args: Any, **_kwargs: Any) -> NoReturn:
|
|
187
|
+
raise OSError("network access is disabled while parsing documents")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _deny_network() -> None:
|
|
191
|
+
class _NoSocket(socket.socket):
|
|
192
|
+
def __init__(self, *_args: Any, **_kwargs: Any) -> None:
|
|
193
|
+
_refuse()
|
|
194
|
+
|
|
195
|
+
socket.socket = _NoSocket # type: ignore[misc]
|
|
196
|
+
for name in ("create_connection", "create_server", "getaddrinfo", "socketpair", "fromfd"):
|
|
197
|
+
setattr(socket, name, _refuse)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _limit(cpu_s: int, memory_bytes: int) -> None:
|
|
201
|
+
try:
|
|
202
|
+
import resource
|
|
203
|
+
except ImportError: # not POSIX: the parent's wall-clock limit still applies
|
|
204
|
+
return
|
|
205
|
+
limits = (
|
|
206
|
+
(resource.RLIMIT_CPU, cpu_s, cpu_s + 1),
|
|
207
|
+
(resource.RLIMIT_AS, memory_bytes, memory_bytes),
|
|
208
|
+
(resource.RLIMIT_CORE, 0, 0),
|
|
209
|
+
)
|
|
210
|
+
for which, soft, hard in limits:
|
|
211
|
+
_, current_hard = resource.getrlimit(which)
|
|
212
|
+
if current_hard != resource.RLIM_INFINITY:
|
|
213
|
+
soft, hard = min(soft, current_hard), min(hard, current_hard)
|
|
214
|
+
# Some systems (macOS for RLIMIT_AS) refuse or ignore a limit; the others still hold.
|
|
215
|
+
with contextlib.suppress(ValueError, OSError):
|
|
216
|
+
resource.setrlimit(which, (soft, hard))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _function(target: str) -> Callable[[bytes, str], ParsedDocument]:
|
|
220
|
+
module, _, name = target.partition(":")
|
|
221
|
+
function: Callable[[bytes, str], ParsedDocument] = getattr(
|
|
222
|
+
importlib.import_module(module), name
|
|
223
|
+
)
|
|
224
|
+
return function
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _write(answer: dict[str, Any]) -> None:
|
|
228
|
+
sys.stdout.buffer.write(json.dumps(answer).encode("utf-8"))
|
|
229
|
+
sys.stdout.buffer.flush()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
233
|
+
"""Child entry point: parse the bytes of standard input, answer JSON on standard output."""
|
|
234
|
+
parser = argparse.ArgumentParser(prog="lorebase_scribe.extraction.sandbox")
|
|
235
|
+
parser.add_argument("--media-type", required=True)
|
|
236
|
+
parser.add_argument("--cpu-s", type=int, required=True)
|
|
237
|
+
parser.add_argument("--memory-bytes", type=int, required=True)
|
|
238
|
+
parser.add_argument("--max-chars", type=int, required=True)
|
|
239
|
+
parser.add_argument("--target", default=DEFAULT_TARGET)
|
|
240
|
+
parser.add_argument("--path", action="append", default=[])
|
|
241
|
+
args = parser.parse_args(argv)
|
|
242
|
+
_limit(args.cpu_s, args.memory_bytes)
|
|
243
|
+
_deny_network()
|
|
244
|
+
sys.path[:0] = args.path
|
|
245
|
+
data = sys.stdin.buffer.read()
|
|
246
|
+
try:
|
|
247
|
+
parsed = _function(args.target)(data, args.media_type)
|
|
248
|
+
except ParseError as error:
|
|
249
|
+
_write({"error": str(error)})
|
|
250
|
+
return 0
|
|
251
|
+
except MemoryError:
|
|
252
|
+
_write({"error": "parsing ran out of memory (scribe.extraction.parser.memory_mb)"})
|
|
253
|
+
return 0
|
|
254
|
+
except Exception as error: # any parser failure is one of the document
|
|
255
|
+
_write({"error": f"the parser failed on this document ({type(error).__name__})"})
|
|
256
|
+
return 0
|
|
257
|
+
_write(
|
|
258
|
+
{
|
|
259
|
+
"text": parsed.text[: args.max_chars],
|
|
260
|
+
"media_type": parsed.media_type,
|
|
261
|
+
"pages": list(parsed.pages),
|
|
262
|
+
"warnings": list(parsed.warnings),
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
sys.exit(main())
|