datashare-python 0.11.3__tar.gz → 0.12.0__tar.gz
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.
- {datashare_python-0.11.3 → datashare_python-0.12.0}/PKG-INFO +1 -1
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/mimetypes_.py +4 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/objects.py +368 -109
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/utils.py +219 -73
- datashare_python-0.12.0/datashare_python/worker-template.tar.gz +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/pyproject.toml +1 -1
- datashare_python-0.11.3/datashare_python/worker-template.tar.gz +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/.gitignore +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/README.md +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/.gitignore +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/__init__.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/__main__.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/cli/__init__.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/cli/project.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/cli/task.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/cli/utils.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/cli/worker.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/config.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/conftest.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/constants.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/dependencies.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/discovery.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/exceptions.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/interceptors.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/logging_.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/task_client.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/template.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/types_.py +0 -0
- {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/worker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: datashare-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Manage Python tasks and local resources in Datashare
|
|
5
5
|
Project-URL: Homepage, https://icij.github.io/datashare-python/
|
|
6
6
|
Project-URL: Documentation, https://icij.github.io/datashare-python/
|
|
@@ -75,6 +75,7 @@ def types_map() -> dict[str, set[str]]:
|
|
|
75
75
|
".bmp": {"image/bmp"},
|
|
76
76
|
".book": {"application/vnd.framemaker"},
|
|
77
77
|
".box": {"application/vnd.previewsystems.box"},
|
|
78
|
+
".boxnote": {"application/octet-stream"},
|
|
78
79
|
".boz": {"application/x-bzip2"},
|
|
79
80
|
".bpk": {"application/octet-stream"},
|
|
80
81
|
".bufr": {"application/x-bufr"},
|
|
@@ -172,6 +173,7 @@ def types_map() -> dict[str, set[str]]:
|
|
|
172
173
|
".davmount": {"application/davmount+xml"},
|
|
173
174
|
".dbf": {"application/dbf", "application/dbase"},
|
|
174
175
|
".dbk": {"application/docbook+xml"},
|
|
176
|
+
".dclx": {"application/octet-stream"},
|
|
175
177
|
".dcr": {"application/x-director"},
|
|
176
178
|
".dcurl": {"text/vnd.curl.dcurl"},
|
|
177
179
|
".dcx": {"image/vnd.zbrush.dcx", "image/x-dcx"},
|
|
@@ -226,6 +228,8 @@ def types_map() -> dict[str, set[str]]:
|
|
|
226
228
|
".dxf": {"image/vnd.dxf"},
|
|
227
229
|
".dxp": {"application/vnd.spotfire.dxp"},
|
|
228
230
|
".dxr": {"application/x-director"},
|
|
231
|
+
".ebc": {"application/octet-stream"},
|
|
232
|
+
".ebcdic": {"application/octet-stream"},
|
|
229
233
|
".ecelp4800": {"audio/vnd.nuera.ecelp4800"},
|
|
230
234
|
".ecelp7470": {"audio/vnd.nuera.ecelp7470"},
|
|
231
235
|
".ecelp9600": {"audio/vnd.nuera.ecelp9600"},
|
|
@@ -2,14 +2,15 @@ import hashlib
|
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
|
-
|
|
6
|
-
from
|
|
5
|
+
import traceback
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Callable
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
from datetime import UTC, datetime
|
|
9
10
|
from enum import StrEnum, unique
|
|
10
11
|
from io import BytesIO
|
|
11
12
|
from pathlib import Path
|
|
12
|
-
from typing import Annotated, Any, ClassVar, Literal, Self, TypeVar, cast
|
|
13
|
+
from typing import Annotated, Any, ClassVar, Literal, Self, TypeVar, cast, final
|
|
13
14
|
|
|
14
15
|
import langcodes
|
|
15
16
|
from icij_common.registrable import Registrable
|
|
@@ -50,6 +51,7 @@ from pydantic import (
|
|
|
50
51
|
Discriminator,
|
|
51
52
|
Field,
|
|
52
53
|
GetCoreSchemaHandler,
|
|
54
|
+
Tag,
|
|
53
55
|
TypeAdapter,
|
|
54
56
|
model_validator,
|
|
55
57
|
)
|
|
@@ -59,8 +61,9 @@ from pydantic.main import IncEx
|
|
|
59
61
|
logger = logging.getLogger(__name__)
|
|
60
62
|
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
Routing = str
|
|
65
|
+
DocID = str
|
|
66
|
+
DocRoute = tuple[DocID, Routing]
|
|
64
67
|
|
|
65
68
|
|
|
66
69
|
class BaseModel(_BaseModel):
|
|
@@ -118,68 +121,34 @@ class DatashareLanguage(str):
|
|
|
118
121
|
return self.as_language_name.alpha3
|
|
119
122
|
|
|
120
123
|
|
|
121
|
-
class WorkerPaths(BaseModel):
|
|
122
|
-
filesystem: Path
|
|
123
|
-
artifacts: Path
|
|
124
|
-
workdir: Path
|
|
125
|
-
|
|
126
|
-
|
|
127
124
|
@unique
|
|
128
|
-
class
|
|
125
|
+
class FileLocation(StrEnum):
|
|
129
126
|
FILESYSTEM = "filesystem"
|
|
130
127
|
ARTIFACTS = "artifacts"
|
|
131
128
|
WORKDIR = "workdir"
|
|
132
129
|
|
|
133
130
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
)
|
|
139
|
-
return value
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
class ProcessedFile(BaseModel):
|
|
143
|
-
id: str
|
|
144
|
-
path: Annotated[Path, AfterValidator(_is_relative)]
|
|
145
|
-
project: str
|
|
146
|
-
location: DocumentLocation
|
|
147
|
-
resource_name: str
|
|
148
|
-
n_pages: int
|
|
149
|
-
parent: "ProcessedFile | None" = None
|
|
150
|
-
|
|
151
|
-
@classmethod
|
|
152
|
-
def from_doc(cls, doc: "Document") -> Self:
|
|
153
|
-
return doc.to_processed_file()
|
|
154
|
-
|
|
155
|
-
def child(self, path: Path, paths: WorkerPaths) -> Self:
|
|
156
|
-
return ProcessedFile(
|
|
157
|
-
id=self.id,
|
|
158
|
-
path=path.relative_to(paths.workdir),
|
|
159
|
-
project=self.project,
|
|
160
|
-
location=DocumentLocation.WORKDIR,
|
|
161
|
-
resource_name=path.name,
|
|
162
|
-
n_pages=self.n_pages,
|
|
163
|
-
parent=self,
|
|
164
|
-
)
|
|
131
|
+
class WorkerPaths(BaseModel):
|
|
132
|
+
filesystem: Path
|
|
133
|
+
artifacts: Path
|
|
134
|
+
workdir: Path
|
|
165
135
|
|
|
166
|
-
def locate(self,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
case
|
|
171
|
-
return
|
|
172
|
-
case
|
|
173
|
-
|
|
174
|
-
return paths.artifacts / artifacts_dir(self.id, project=project) / "raw"
|
|
175
|
-
case DocumentLocation.WORKDIR:
|
|
176
|
-
return paths.workdir / self.path
|
|
136
|
+
def locate(self, path: Path, location: FileLocation) -> Path:
|
|
137
|
+
match location:
|
|
138
|
+
case FileLocation.FILESYSTEM:
|
|
139
|
+
return self.filesystem / path
|
|
140
|
+
case FileLocation.ARTIFACTS:
|
|
141
|
+
return self.artifacts / path
|
|
142
|
+
case FileLocation.WORKDIR:
|
|
143
|
+
return self.workdir / path
|
|
177
144
|
case _:
|
|
178
|
-
raise ValueError(f"invalid location: {
|
|
145
|
+
raise ValueError(f"invalid location: {path}")
|
|
179
146
|
|
|
180
147
|
|
|
181
|
-
class
|
|
182
|
-
|
|
148
|
+
class FromParent[P](ABC):
|
|
149
|
+
@classmethod
|
|
150
|
+
@abstractmethod
|
|
151
|
+
def from_parent(cls, parent: P, *args, **kwargs) -> Self: ...
|
|
183
152
|
|
|
184
153
|
|
|
185
154
|
class IETFLanguage(str):
|
|
@@ -213,12 +182,9 @@ class Translation(BaseModel): # No camelcase here we don't know why
|
|
|
213
182
|
content: Annotated[str, BeforeValidator(_from_sentences)]
|
|
214
183
|
|
|
215
184
|
|
|
216
|
-
Routing = str
|
|
217
|
-
DocID = str
|
|
218
|
-
DocRoute = tuple[DocID, Routing]
|
|
219
|
-
|
|
220
|
-
|
|
221
185
|
class Document(DatashareModel):
|
|
186
|
+
"""ES datashare document"""
|
|
187
|
+
|
|
222
188
|
id: str
|
|
223
189
|
language: DatashareLanguage
|
|
224
190
|
index: str | None = None
|
|
@@ -285,7 +251,7 @@ class Document(DatashareModel):
|
|
|
285
251
|
id=es_doc[ID_],
|
|
286
252
|
index=es_doc.get(INDEX_),
|
|
287
253
|
content=sources.get(DOC_CONTENT),
|
|
288
|
-
content_translated=sources.get(DOC_CONTENT_TRANSLATED
|
|
254
|
+
content_translated=sources.get(DOC_CONTENT_TRANSLATED),
|
|
289
255
|
content_text_length=sources.get("content_text_length"),
|
|
290
256
|
language=DatashareLanguage(sources[DOC_LANGUAGE]),
|
|
291
257
|
root_document=sources.get(DOC_ROOT_ID),
|
|
@@ -295,58 +261,57 @@ class Document(DatashareModel):
|
|
|
295
261
|
metadata=sources.get(DOC_METADATA),
|
|
296
262
|
)
|
|
297
263
|
|
|
298
|
-
|
|
299
|
-
|
|
264
|
+
@property
|
|
265
|
+
def project(self) -> str:
|
|
266
|
+
if self.index is None:
|
|
267
|
+
raise ValueError("missing index")
|
|
268
|
+
return self.index
|
|
300
269
|
|
|
270
|
+
@property
|
|
271
|
+
def doc_id(self) -> str:
|
|
272
|
+
return self.id
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def route(self) -> DocRoute:
|
|
276
|
+
if self.root_document:
|
|
277
|
+
return self.id, self.id
|
|
278
|
+
return self.id, self.root_document
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def resource_name(self) -> str:
|
|
301
282
|
if self.metadata is None:
|
|
302
|
-
raise ValueError(
|
|
303
|
-
"can't compute filesystem path for document withtout metadata"
|
|
304
|
-
)
|
|
283
|
+
raise ValueError("missing metadata")
|
|
305
284
|
resource_name = self.metadata.get(TIKA_METADATA_RESOURCENAME)
|
|
306
285
|
if resource_name is None:
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
resource_name = cast(str, resource_name)
|
|
318
|
-
if self.is_root_document:
|
|
319
|
-
path = self.path
|
|
320
|
-
location = DocumentLocation.FILESYSTEM
|
|
321
|
-
else:
|
|
322
|
-
if self.index is None:
|
|
323
|
-
msg = (
|
|
324
|
-
f"can't compute filesystem path for embedded doc {self.id} without"
|
|
325
|
-
f" index"
|
|
326
|
-
)
|
|
327
|
-
raise ValueError(msg)
|
|
328
|
-
path = artifacts_dir(doc_id=self.id, project=self.index) / "raw"
|
|
329
|
-
location = DocumentLocation.ARTIFACTS
|
|
330
|
-
# The filesystem dod is alway relative to the base location, let's make sure
|
|
331
|
-
# we store a relative path otherwise joining with the location will fail
|
|
332
|
-
if path.parts and path.parts[0] == os.path.sep:
|
|
333
|
-
path = Path(*path.parts[1:])
|
|
334
|
-
n_pages = 1
|
|
335
|
-
if self.metadata:
|
|
336
|
-
n_pages = self.metadata.get("tika_metadata_xmptpg_npages", n_pages)
|
|
337
|
-
return ProcessedFile(
|
|
338
|
-
id=self.id,
|
|
339
|
-
path=path,
|
|
340
|
-
project=self.index,
|
|
341
|
-
location=location,
|
|
342
|
-
resource_name=resource_name,
|
|
343
|
-
n_pages=n_pages,
|
|
286
|
+
msg = f"missing {TIKA_METADATA_RESOURCENAME} in metadata"
|
|
287
|
+
raise KeyError(msg)
|
|
288
|
+
cast(str, resource_name)
|
|
289
|
+
return resource_name
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _is_relative(value: Path) -> Path:
|
|
293
|
+
if value.is_absolute():
|
|
294
|
+
raise ValueError(
|
|
295
|
+
f"WorkerFilePath path should always be relative, found {value}"
|
|
344
296
|
)
|
|
297
|
+
return value
|
|
345
298
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
299
|
+
|
|
300
|
+
class WorkerFilePath(BaseModel):
|
|
301
|
+
"""Path of a file produced or processed by a worker, located somewhere on the DS
|
|
302
|
+
filesystem, artifactsdir or workdir
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
path: Annotated[Path, AfterValidator(_is_relative)]
|
|
306
|
+
location: FileLocation
|
|
307
|
+
|
|
308
|
+
def locate(self, paths: WorkerPaths) -> Path:
|
|
309
|
+
return paths.locate(self.path, self.location)
|
|
310
|
+
|
|
311
|
+
@classmethod
|
|
312
|
+
def relative_to_workdir(cls, path: Path, paths: WorkerPaths) -> Self:
|
|
313
|
+
path = path.relative_to(paths.workdir)
|
|
314
|
+
return cls(path=path, location=FileLocation.WORKDIR)
|
|
350
315
|
|
|
351
316
|
|
|
352
317
|
def _is_absolute_path(v: bytes | BytesIO | Path) -> Any:
|
|
@@ -617,3 +582,297 @@ class TaskGroup:
|
|
|
617
582
|
@classmethod
|
|
618
583
|
def python(cls) -> Self:
|
|
619
584
|
return cls(name="PYTHON")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class Error(BaseModel):
|
|
588
|
+
title: str
|
|
589
|
+
detail: str | None
|
|
590
|
+
|
|
591
|
+
@classmethod
|
|
592
|
+
def from_exception(cls, exception: BaseException) -> "Error":
|
|
593
|
+
title = exception.__class__.__name__
|
|
594
|
+
trace_lines = traceback.format_exception(
|
|
595
|
+
None, value=exception, tb=exception.__traceback__
|
|
596
|
+
)
|
|
597
|
+
detail = f"{exception}\n{''.join(trace_lines)}"
|
|
598
|
+
error = Error(title=title, detail=detail)
|
|
599
|
+
return error
|
|
600
|
+
|
|
601
|
+
def without_detail(self) -> "Error":
|
|
602
|
+
return Error(title=self.title, detail=None)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
class ErrorSource(BaseModel, ABC):
|
|
606
|
+
"""Source of the processing error."""
|
|
607
|
+
|
|
608
|
+
doc_id: str
|
|
609
|
+
project: str
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class ErrorSourceWithPages(ErrorSource, ABC):
|
|
613
|
+
"""Source of the processing error and number of pages processed at that time"""
|
|
614
|
+
|
|
615
|
+
n_pages: int
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
class ProcessingError[S: ErrorSource](BaseModel):
|
|
619
|
+
"""Error occurring while processing a document or intermediate result"""
|
|
620
|
+
|
|
621
|
+
source: S
|
|
622
|
+
error: Error
|
|
623
|
+
|
|
624
|
+
@final
|
|
625
|
+
@classmethod
|
|
626
|
+
def from_exception(cls, source: S, exception: BaseException) -> Self:
|
|
627
|
+
return cls(source=source, error=Error.from_exception(exception))
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
class ProcessingReport(DatashareModel):
|
|
631
|
+
"""Basic processing report indicating how many docs where processed"""
|
|
632
|
+
|
|
633
|
+
n_docs: int = 0
|
|
634
|
+
|
|
635
|
+
def __add__(self, other: Self) -> Self:
|
|
636
|
+
return ProcessingReport(n_docs=other.n_docs + self.n_docs)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class ProcessingReportWithPages(ProcessingReport):
|
|
640
|
+
"""Processing report indicating how many pages where processed"""
|
|
641
|
+
|
|
642
|
+
n_pages: int = 0
|
|
643
|
+
|
|
644
|
+
def __add__(self, other: Self) -> Self:
|
|
645
|
+
return ProcessingReportWithPages(
|
|
646
|
+
n_docs=other.n_docs + self.n_docs, n_pages=other.n_pages + self.n_pages
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _without_detail(errors: list[Error]) -> list[Error]:
|
|
651
|
+
if any(e.detail is not None for e in errors):
|
|
652
|
+
msg = "expected errors without details"
|
|
653
|
+
raise ValueError(msg)
|
|
654
|
+
return errors
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
class DocProcessingErrors(BaseModel):
|
|
658
|
+
"""All errors related to several processing happening on a single document.
|
|
659
|
+
If a source doc has many children (typically a document split into pages
|
|
660
|
+
processed separately), all children errors are merged under the older parent.
|
|
661
|
+
"""
|
|
662
|
+
|
|
663
|
+
doc_id: str
|
|
664
|
+
root_document: str
|
|
665
|
+
project: str
|
|
666
|
+
errors: Annotated[
|
|
667
|
+
list[Error], AfterValidator(_without_detail), Field(default_factory=list)
|
|
668
|
+
]
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
class ErrorReport(ProcessingReport):
|
|
672
|
+
"""Processing report indicated how many docs failed and listing errors per doc."""
|
|
673
|
+
|
|
674
|
+
errors: list[DocProcessingErrors] = Field(default_factory=list)
|
|
675
|
+
|
|
676
|
+
def __add__(self, other: Self) -> Self:
|
|
677
|
+
return ErrorReportWithPages(
|
|
678
|
+
n_docs=other.n_docs + self.n_docs, errors=self.errors + other.errors
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
@classmethod
|
|
682
|
+
def from_errors(cls, *errors: ProcessingError) -> Self:
|
|
683
|
+
"""Aggregate errors by root source documents"""
|
|
684
|
+
roots = dict()
|
|
685
|
+
for error in errors:
|
|
686
|
+
root = error.source
|
|
687
|
+
while (parent := getattr(root, "parent", None)) is not None:
|
|
688
|
+
root = parent
|
|
689
|
+
root_errors = roots.get(root.doc_id)
|
|
690
|
+
if root_errors is not None:
|
|
691
|
+
_, root_errors = root_errors
|
|
692
|
+
else:
|
|
693
|
+
root_errors = []
|
|
694
|
+
root_errors.append(error)
|
|
695
|
+
roots[root.doc_id] = (root, root_errors)
|
|
696
|
+
errors = []
|
|
697
|
+
for _, (root, root_errors) in sorted(roots.items()):
|
|
698
|
+
# We keep details for errors stored as intermediate steps to allow
|
|
699
|
+
# debugging, we don't output them in the response to keep everything
|
|
700
|
+
# lightweight and more secure
|
|
701
|
+
root_errors = [err.error.without_detail() for err in root_errors] # noqa: PLW2901
|
|
702
|
+
doc_errors = DocProcessingErrors(
|
|
703
|
+
doc_id=root.doc_id,
|
|
704
|
+
project=root.project,
|
|
705
|
+
root_document=root.root_document,
|
|
706
|
+
errors=root_errors,
|
|
707
|
+
)
|
|
708
|
+
errors.append(doc_errors)
|
|
709
|
+
return cls(n_docs=len(errors), errors=errors)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
class ErrorReportWithPages(ProcessingReportWithPages, ABC):
|
|
713
|
+
"""Same as ErrorReport but providing details about how many pages failed"""
|
|
714
|
+
|
|
715
|
+
errors: list[DocProcessingErrors] = Field(default_factory=list)
|
|
716
|
+
|
|
717
|
+
def __add__(self, other: Self) -> Self:
|
|
718
|
+
return ErrorReportWithPages(
|
|
719
|
+
n_docs=other.n_docs + self.n_docs,
|
|
720
|
+
n_pages=other.n_pages + self.n_pages,
|
|
721
|
+
errors=self.errors + other.errors,
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
@classmethod
|
|
725
|
+
def from_errors(cls, *errors: ProcessingError) -> Self:
|
|
726
|
+
"""Aggregate errors by root source documents"""
|
|
727
|
+
roots = dict()
|
|
728
|
+
n_pages = 0
|
|
729
|
+
for error in errors:
|
|
730
|
+
root = error.source
|
|
731
|
+
while (parent := getattr(root, "parent", None)) is not None:
|
|
732
|
+
root = parent
|
|
733
|
+
root_errors = roots.get(root.doc_id)
|
|
734
|
+
if root_errors is not None:
|
|
735
|
+
_, root_errors = root_errors
|
|
736
|
+
else:
|
|
737
|
+
root_errors = []
|
|
738
|
+
n_pages += error.source.n_pages
|
|
739
|
+
root_errors.append(error)
|
|
740
|
+
roots[root.doc_id] = (root, root_errors)
|
|
741
|
+
errors = []
|
|
742
|
+
for _, (root, root_errors) in sorted(roots.items()):
|
|
743
|
+
# We keep details for errors stored as intermediate steps to allow
|
|
744
|
+
# debugging, we don't output them in the response to keep everything
|
|
745
|
+
# lightweight and more secure
|
|
746
|
+
root_errors = [err.error.without_detail() for err in root_errors] # noqa: PLW2901
|
|
747
|
+
doc_errors = DocProcessingErrors(
|
|
748
|
+
doc_id=root.doc_id,
|
|
749
|
+
project=root.project,
|
|
750
|
+
root_document=root.root_document,
|
|
751
|
+
errors=root_errors,
|
|
752
|
+
)
|
|
753
|
+
errors.append(doc_errors)
|
|
754
|
+
return cls(n_docs=len(errors), n_pages=n_pages, errors=errors)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
class ProcessingResult[V](BaseModel):
|
|
758
|
+
value: V
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
class WithParent[P](FromParent[P]):
|
|
762
|
+
"""Result produced from another processing result
|
|
763
|
+
|
|
764
|
+
Tracking parent allows track errors from one intermediate results to another back
|
|
765
|
+
to the initial DS Document from the index. It also allows error aggregation.
|
|
766
|
+
"""
|
|
767
|
+
|
|
768
|
+
parent: P
|
|
769
|
+
|
|
770
|
+
@property
|
|
771
|
+
def doc_id(self) -> str:
|
|
772
|
+
return self.parent.doc_id
|
|
773
|
+
|
|
774
|
+
@property
|
|
775
|
+
def root_document(self) -> str:
|
|
776
|
+
return self.parent.root_document
|
|
777
|
+
|
|
778
|
+
@property
|
|
779
|
+
def project(self) -> str:
|
|
780
|
+
return self.parent.project
|
|
781
|
+
|
|
782
|
+
@property
|
|
783
|
+
def route(self) -> DocRoute:
|
|
784
|
+
return self.parent.route
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
class DatashareFile(
|
|
788
|
+
WithParent[Document], ProcessingResult[WorkerFilePath], ErrorSourceWithPages
|
|
789
|
+
):
|
|
790
|
+
"""A datashare document file ready to be processed and located somewhere on the
|
|
791
|
+
filesystem or artifact directory.
|
|
792
|
+
"""
|
|
793
|
+
|
|
794
|
+
parent: Document
|
|
795
|
+
|
|
796
|
+
def locate(self, paths: WorkerPaths) -> Path:
|
|
797
|
+
return self.value.locate(paths)
|
|
798
|
+
|
|
799
|
+
@classmethod
|
|
800
|
+
def from_parent(cls, parent: Document) -> Self:
|
|
801
|
+
from .utils import artifacts_dir # noqa: PLC0415
|
|
802
|
+
|
|
803
|
+
if parent.metadata is None:
|
|
804
|
+
raise ValueError(
|
|
805
|
+
"can't compute filesystem path for document withtout metadata"
|
|
806
|
+
)
|
|
807
|
+
if parent.is_root_document:
|
|
808
|
+
path = parent.path
|
|
809
|
+
if path is None:
|
|
810
|
+
msg = (
|
|
811
|
+
"can't create a Datashare file from ES document without path, "
|
|
812
|
+
"path is needed to locate the file, retrieve it from ES"
|
|
813
|
+
)
|
|
814
|
+
raise ValueError(msg)
|
|
815
|
+
location = FileLocation.FILESYSTEM
|
|
816
|
+
else:
|
|
817
|
+
path = artifacts_dir(doc_id=parent.id, project=parent.project) / "raw"
|
|
818
|
+
location = FileLocation.ARTIFACTS
|
|
819
|
+
# The filesystem dod is alway relative to the base location, let's make sure
|
|
820
|
+
# we store a relative path otherwise joining with the location will fail
|
|
821
|
+
if path.parts and path.parts[0] == os.path.sep:
|
|
822
|
+
path = Path(*path.parts[1:])
|
|
823
|
+
n_pages = 1
|
|
824
|
+
if parent.metadata:
|
|
825
|
+
n_pages = parent.metadata.get("tika_metadata_xmptpg_npages", n_pages)
|
|
826
|
+
path = WorkerFilePath(path=path, location=location)
|
|
827
|
+
return cls(
|
|
828
|
+
doc_id=parent.id,
|
|
829
|
+
parent=parent,
|
|
830
|
+
value=path,
|
|
831
|
+
n_pages=n_pages,
|
|
832
|
+
project=parent.project,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
class WorkerFile(
|
|
837
|
+
WithParent["ProcessedFile"], ProcessingResult[WorkerFilePath], ErrorSourceWithPages
|
|
838
|
+
):
|
|
839
|
+
"""An intermediate results produce by the worker either from a Datashare doc on the
|
|
840
|
+
filesystem or from another intermediate result
|
|
841
|
+
"""
|
|
842
|
+
|
|
843
|
+
parent: "ProcessedFile"
|
|
844
|
+
|
|
845
|
+
def locate(self, paths: WorkerPaths) -> Path:
|
|
846
|
+
return self.value.locate(paths)
|
|
847
|
+
|
|
848
|
+
@classmethod
|
|
849
|
+
def from_parent(
|
|
850
|
+
cls, parent: "ProcessedFile", path: Path, paths: WorkerPaths
|
|
851
|
+
) -> Self:
|
|
852
|
+
path = WorkerFilePath.relative_to_workdir(path, paths)
|
|
853
|
+
return cls(
|
|
854
|
+
doc_id=parent.doc_id,
|
|
855
|
+
project=parent.project,
|
|
856
|
+
parent=parent,
|
|
857
|
+
value=path,
|
|
858
|
+
n_pages=parent.n_pages,
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _file_discriminator(v: Any) -> str:
|
|
863
|
+
parent = v.get("parent") if isinstance(v, dict) else getattr(v, "parent", None)
|
|
864
|
+
if parent is None:
|
|
865
|
+
raise ValueError(f"{v} has no parent")
|
|
866
|
+
if isinstance(parent, dict):
|
|
867
|
+
is_document = parent.get("type") == "Document"
|
|
868
|
+
else:
|
|
869
|
+
is_document = isinstance(parent, Document)
|
|
870
|
+
return "datashare" if is_document else "worker"
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
ProcessedFile = Annotated[
|
|
874
|
+
Annotated[DatashareFile, Tag("datashare")] | Annotated[WorkerFile, Tag("worker")],
|
|
875
|
+
Discriminator(_file_discriminator),
|
|
876
|
+
]
|
|
877
|
+
WorkerFile.model_rebuild()
|
|
878
|
+
PROCESSED_FILE_TA = TypeAdapter(ProcessedFile, config=BaseModel.model_config)
|
|
@@ -10,6 +10,7 @@ import sys
|
|
|
10
10
|
import threading
|
|
11
11
|
import time
|
|
12
12
|
from collections.abc import (
|
|
13
|
+
AsyncGenerator,
|
|
13
14
|
AsyncIterable,
|
|
14
15
|
Awaitable,
|
|
15
16
|
Callable,
|
|
@@ -25,13 +26,13 @@ from functools import cache, wraps
|
|
|
25
26
|
from hashlib import sha256
|
|
26
27
|
from io import BytesIO
|
|
27
28
|
from pathlib import Path
|
|
28
|
-
from typing import Any, Self, TypeVar
|
|
29
|
+
from typing import Any, Protocol, Self, TypeVar
|
|
29
30
|
from uuid import uuid4
|
|
30
31
|
|
|
31
32
|
import temporalio
|
|
32
33
|
from aiofile import async_open
|
|
33
34
|
from lru import LRU
|
|
34
|
-
from pydantic import ValidationError
|
|
35
|
+
from pydantic import TypeAdapter, ValidationError
|
|
35
36
|
from pydantic.alias_generators import to_snake
|
|
36
37
|
from temporalio import activity, workflow
|
|
37
38
|
from temporalio.api.common.v1 import Payload
|
|
@@ -56,9 +57,13 @@ from .constants import MANIFEST_JSON, METADATA_JSON
|
|
|
56
57
|
from .mimetypes_ import types_map
|
|
57
58
|
from .objects import (
|
|
58
59
|
BaseModel,
|
|
60
|
+
DatashareFile,
|
|
59
61
|
DocArtifact,
|
|
60
|
-
|
|
62
|
+
Document,
|
|
63
|
+
ErrorSource,
|
|
61
64
|
ProcessedFile,
|
|
65
|
+
ProcessingError,
|
|
66
|
+
WorkerFile,
|
|
62
67
|
WorkerPaths,
|
|
63
68
|
)
|
|
64
69
|
from .types_ import RawAsyncProgressHandler
|
|
@@ -388,8 +393,7 @@ def to_scaled_async_progress(
|
|
|
388
393
|
def safe_dir(doc_id: str) -> Path:
|
|
389
394
|
if len(doc_id) < 4:
|
|
390
395
|
raise ValueError(f"expected doc_id to be at least 4, found {doc_id}")
|
|
391
|
-
|
|
392
|
-
return Path(*parts)
|
|
396
|
+
return Path(doc_id[:2], doc_id[2:4])
|
|
393
397
|
|
|
394
398
|
|
|
395
399
|
def artifacts_dir(doc_id: str, *, project: str) -> Path:
|
|
@@ -607,31 +611,18 @@ def activity_workdir(
|
|
|
607
611
|
|
|
608
612
|
|
|
609
613
|
def symlink_embedded_document_to_workdir(
|
|
610
|
-
doc:
|
|
614
|
+
doc: Document, paths: WorkerPaths
|
|
611
615
|
) -> ProcessedFile:
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
os.symlink(artifact_path, symlink_path)
|
|
623
|
-
return ProcessedFile(
|
|
624
|
-
path=symlink_path.relative_to(paths.workdir),
|
|
625
|
-
id=doc.id,
|
|
626
|
-
location=DocumentLocation.WORKDIR,
|
|
627
|
-
project=doc.project,
|
|
628
|
-
resource_name=doc.resource_name,
|
|
629
|
-
n_pages=doc.n_pages,
|
|
630
|
-
)
|
|
631
|
-
case DocumentLocation.FILESYSTEM:
|
|
632
|
-
return doc
|
|
633
|
-
case _:
|
|
634
|
-
raise ValueError(f"unsupported location {doc.location}")
|
|
616
|
+
ds_file = DatashareFile.from_parent(doc)
|
|
617
|
+
if doc.is_root_document:
|
|
618
|
+
return ds_file
|
|
619
|
+
symlinks_dir = paths.workdir / doc.project / "symlinks"
|
|
620
|
+
symlink_path = symlinks_dir / safe_dir(doc.id) / doc.id / doc.resource_name
|
|
621
|
+
symlink_path.parent.mkdir(parents=True, exist_ok=True)
|
|
622
|
+
raw_path = ds_file.locate(paths)
|
|
623
|
+
with contextlib.suppress(FileExistsError):
|
|
624
|
+
os.symlink(raw_path, symlink_path)
|
|
625
|
+
return WorkerFile.from_parent(ds_file, symlink_path, paths)
|
|
635
626
|
|
|
636
627
|
|
|
637
628
|
def artifact_path(
|
|
@@ -644,7 +635,7 @@ def artifact_path(
|
|
|
644
635
|
return root / artifacts_dir(doc_id, project=project) / artifact_type.filename
|
|
645
636
|
|
|
646
637
|
|
|
647
|
-
def
|
|
638
|
+
def read_jsonl(path: Path) -> Iterable[dict]:
|
|
648
639
|
with path.open() as f:
|
|
649
640
|
for line in f:
|
|
650
641
|
line = line.strip() # noqa: PLW2901
|
|
@@ -655,18 +646,82 @@ def _read_jsonl(path: Path) -> Iterable[dict]:
|
|
|
655
646
|
M = TypeVar("M", bound=BaseModel)
|
|
656
647
|
|
|
657
648
|
|
|
658
|
-
def read_jsonl_as[M](
|
|
659
|
-
|
|
649
|
+
def read_jsonl_as[M](
|
|
650
|
+
path: Path, processed_file_cls: type[M] | TypeAdapter[M]
|
|
651
|
+
) -> Iterable[M]:
|
|
652
|
+
if isinstance(processed_file_cls, TypeAdapter):
|
|
653
|
+
deser_fn = processed_file_cls.validate_python
|
|
654
|
+
else:
|
|
655
|
+
deser_fn = processed_file_cls.model_validate
|
|
656
|
+
return (deser_fn(d) for d in read_jsonl(path))
|
|
660
657
|
|
|
661
658
|
|
|
662
659
|
async def async_read_jsonl_as[M](
|
|
663
|
-
path: Path, processed_file_cls: type[M]
|
|
660
|
+
path: Path, processed_file_cls: type[M] | TypeAdapter[M]
|
|
664
661
|
) -> AsyncIterable[M]:
|
|
662
|
+
if isinstance(processed_file_cls, TypeAdapter):
|
|
663
|
+
deser_fn = processed_file_cls.validate_json
|
|
664
|
+
else:
|
|
665
|
+
deser_fn = processed_file_cls.model_validate_json
|
|
665
666
|
async with async_open(path, "r") as f:
|
|
666
667
|
async for line in f:
|
|
667
668
|
line = line.strip() # noqa: PLW2901
|
|
668
669
|
if line:
|
|
669
|
-
yield
|
|
670
|
+
yield deser_fn(line)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
async def write_batches(
|
|
674
|
+
batches: AsyncIterable[list[ProcessedFile]] | Iterable[list[ProcessedFile]],
|
|
675
|
+
root: Path,
|
|
676
|
+
batch_offset: int = 0,
|
|
677
|
+
prefix: str = "batch_",
|
|
678
|
+
) -> AsyncIterable[Path]:
|
|
679
|
+
if hasattr(batches, "__aiter__"):
|
|
680
|
+
async for b in _async_write_batches(batches, root, batch_offset, prefix):
|
|
681
|
+
yield b
|
|
682
|
+
return
|
|
683
|
+
async for b in _write_batches(batches, root, batch_offset, prefix):
|
|
684
|
+
yield b
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
async def _async_write_batches(
|
|
688
|
+
batches: AsyncIterable[list[ProcessedFile]],
|
|
689
|
+
root: Path,
|
|
690
|
+
batch_offset: int,
|
|
691
|
+
prefix: str,
|
|
692
|
+
) -> AsyncIterable[Path]:
|
|
693
|
+
batch_id = batch_offset
|
|
694
|
+
async for batch in batches:
|
|
695
|
+
batch_path = root / f"{batch_id // 1000}" / f"{prefix}{batch_id}.jsonl"
|
|
696
|
+
batch_path.parent.mkdir(parents=True, exist_ok=True)
|
|
697
|
+
async with async_open(batch_path, "w") as f:
|
|
698
|
+
for fs_doc in batch:
|
|
699
|
+
serialized = fs_doc.model_dump_json(
|
|
700
|
+
exclude_none=True, polymorphic_serialization=True
|
|
701
|
+
)
|
|
702
|
+
await f.write(f"{serialized}\n")
|
|
703
|
+
yield batch_path
|
|
704
|
+
batch_id += 1
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
async def _write_batches(
|
|
708
|
+
batches: Iterable[list[ProcessedFile]],
|
|
709
|
+
root: Path,
|
|
710
|
+
batch_offset: int,
|
|
711
|
+
prefix: str,
|
|
712
|
+
) -> AsyncIterable[Path]:
|
|
713
|
+
batch_id = batch_offset
|
|
714
|
+
for batch in batches:
|
|
715
|
+
batch_path = root / f"{batch_id // 1000}" / f"{prefix}{batch_id}.jsonl"
|
|
716
|
+
batch_path.parent.mkdir(parents=True, exist_ok=True)
|
|
717
|
+
async with async_open(batch_path, "w") as f:
|
|
718
|
+
for fs_doc in batch:
|
|
719
|
+
serialized = fs_doc.model_dump_json(
|
|
720
|
+
exclude_none=True, polymorphic_serialization=True
|
|
721
|
+
)
|
|
722
|
+
await f.write(f"{serialized}\n")
|
|
723
|
+
yield batch_path
|
|
724
|
+
batch_id += 1
|
|
670
725
|
|
|
671
726
|
|
|
672
727
|
@cache
|
|
@@ -689,6 +744,8 @@ def ext_to_mime_types(ext: str) -> set[str]:
|
|
|
689
744
|
return ext_to_mime_types(".xml")
|
|
690
745
|
case ".dclg.xml":
|
|
691
746
|
return ext_to_mime_types(".xml")
|
|
747
|
+
case ".msg":
|
|
748
|
+
return ext_to_mime_types(".eml")
|
|
692
749
|
try:
|
|
693
750
|
return types_map()[ext]
|
|
694
751
|
except KeyError as e:
|
|
@@ -766,59 +823,108 @@ class SharedResources:
|
|
|
766
823
|
eviction_callback: Callable[[Any, Any], None] | None = None,
|
|
767
824
|
) -> None:
|
|
768
825
|
self._eviction_callback = eviction_callback
|
|
769
|
-
self._cache = LRU(cache_size, self.
|
|
826
|
+
self._cache = LRU(cache_size, self._on_evict)
|
|
770
827
|
self._sentinel = object()
|
|
828
|
+
# Leased resources are tracked by id, evicting a leased resource defers the
|
|
829
|
+
# eviction callback until its last lease is released
|
|
830
|
+
self._lock = threading.Lock()
|
|
831
|
+
self._n_leases: dict[int, int] = dict()
|
|
832
|
+
self._pending_evictions: dict[int, tuple[Any, Any]] = dict()
|
|
771
833
|
|
|
772
834
|
def __enter__(self) -> Self:
|
|
773
835
|
return self
|
|
774
836
|
|
|
775
837
|
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # noqa: ANN001
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
self._eviction_callback(k, v)
|
|
838
|
+
for k, v in self._cache.items():
|
|
839
|
+
self._on_evict(k, v)
|
|
779
840
|
|
|
841
|
+
@contextlib.contextmanager
|
|
780
842
|
def get_or_cache_resource(
|
|
781
843
|
self, key: str, default_factory: Callable[[], Any]
|
|
782
|
-
) -> Any:
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
844
|
+
) -> Generator[Any, None, None]:
|
|
845
|
+
"""Get or cache a resource, leasing it for the duration of the context.
|
|
846
|
+
|
|
847
|
+
A resource evicted while leased stays usable, its eviction callback only runs
|
|
848
|
+
once all its leases have been released.
|
|
849
|
+
|
|
850
|
+
:param key: resource cache key
|
|
851
|
+
:param default_factory: resource factory, called on cache miss
|
|
852
|
+
:return: the cached resource
|
|
853
|
+
"""
|
|
854
|
+
value = self._get_and_acquire(key)
|
|
855
|
+
if value is self._sentinel:
|
|
856
|
+
value = default_factory()
|
|
857
|
+
self._cache_and_acquire(key, value)
|
|
858
|
+
try:
|
|
859
|
+
yield value
|
|
860
|
+
finally:
|
|
861
|
+
self._release(value)
|
|
800
862
|
|
|
863
|
+
@contextlib.asynccontextmanager
|
|
801
864
|
async def async_get_or_cache_resource(
|
|
802
865
|
self, key: str, default_factory: Callable[[], Awaitable[Any]]
|
|
803
|
-
) -> Any:
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
866
|
+
) -> AsyncGenerator[Any, None]:
|
|
867
|
+
"""Async version of get_or_cache_resource.
|
|
868
|
+
|
|
869
|
+
:param key: resource cache key
|
|
870
|
+
:param default_factory: async resource factory, awaited on cache miss
|
|
871
|
+
:return: the cached resource
|
|
872
|
+
"""
|
|
873
|
+
value = self._get_and_acquire(key)
|
|
874
|
+
if value is self._sentinel:
|
|
875
|
+
value = await default_factory()
|
|
876
|
+
self._cache_and_acquire(key, value)
|
|
877
|
+
try:
|
|
878
|
+
yield value
|
|
879
|
+
finally:
|
|
880
|
+
self._release(value)
|
|
881
|
+
|
|
882
|
+
def _get_and_acquire(self, key: str) -> Any:
|
|
883
|
+
with self._lock:
|
|
884
|
+
value = self._cache.get(key, self._sentinel)
|
|
885
|
+
if value is not self._sentinel:
|
|
886
|
+
self._acquire(value)
|
|
820
887
|
return value
|
|
821
888
|
|
|
889
|
+
def _cache_and_acquire(self, key: str, value: Any) -> None:
|
|
890
|
+
# The factory can be long to complete (that's one of the reason we want to
|
|
891
|
+
# cache its results). Because we don't lock the factory call, together with
|
|
892
|
+
# the cache update, in case of concurrent access, quicker factories will
|
|
893
|
+
# complete first and will stay longer in the cache. This is fine, the
|
|
894
|
+
# alternative is to lock factory + cache update to avoid concurrent access but
|
|
895
|
+
# this will mean waiting for the first factory call to complete, this can
|
|
896
|
+
# potentially imply longer waits than no getting the value from the cache
|
|
897
|
+
with self._lock:
|
|
898
|
+
self._acquire(value)
|
|
899
|
+
# Can evict other resources, must be called without holding the lock
|
|
900
|
+
self._cache[key] = value
|
|
901
|
+
|
|
902
|
+
def _acquire(self, value: Any) -> None:
|
|
903
|
+
value_id = id(value)
|
|
904
|
+
self._n_leases[value_id] = self._n_leases.get(value_id, 0) + 1
|
|
905
|
+
|
|
906
|
+
def _release(self, value: Any) -> None:
|
|
907
|
+
value_id = id(value)
|
|
908
|
+
with self._lock:
|
|
909
|
+
self._n_leases[value_id] -= 1
|
|
910
|
+
if self._n_leases[value_id]:
|
|
911
|
+
return
|
|
912
|
+
del self._n_leases[value_id]
|
|
913
|
+
pending = self._pending_evictions.pop(value_id, None)
|
|
914
|
+
if pending is not None:
|
|
915
|
+
self._evict(*pending)
|
|
916
|
+
|
|
917
|
+
def _on_evict(self, key: Any, value: Any) -> None:
|
|
918
|
+
with self._lock:
|
|
919
|
+
if id(value) in self._n_leases:
|
|
920
|
+
self._pending_evictions[id(value)] = (key, value)
|
|
921
|
+
return
|
|
922
|
+
self._evict(key, value)
|
|
923
|
+
|
|
924
|
+
def _evict(self, key: Any, value: Any) -> None:
|
|
925
|
+
if self._eviction_callback is not None:
|
|
926
|
+
self._eviction_callback(key, value)
|
|
927
|
+
|
|
822
928
|
|
|
823
929
|
def close_cm_callback(key: str, value: Any) -> None: # noqa: ARG001
|
|
824
930
|
if hasattr(value, "__exit__"):
|
|
@@ -857,3 +963,43 @@ def config_cache_key(config: BaseModel) -> str:
|
|
|
857
963
|
.replace("_", "-")
|
|
858
964
|
)
|
|
859
965
|
return f"{config_label}-{str(hash(config))}"
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
class _PreprocessingFunction[S: ErrorSource, R](Protocol):
|
|
969
|
+
def __call__(self, doc: S, *args, **kwargs) -> R: ...
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def reports_errors[S: ErrorSource, R](
|
|
973
|
+
errors: tuple[type[Exception]], exc_cls: type[ProcessingError[S]]
|
|
974
|
+
) -> Callable[
|
|
975
|
+
[_PreprocessingFunction[S, R]], _PreprocessingFunction[S, R | ProcessingError[S]]
|
|
976
|
+
]:
|
|
977
|
+
|
|
978
|
+
def parent_wrapper(
|
|
979
|
+
f: _PreprocessingFunction[S, R],
|
|
980
|
+
) -> _PreprocessingFunction[S, R | ProcessingError[S]]:
|
|
981
|
+
if inspect.iscoroutinefunction(f):
|
|
982
|
+
|
|
983
|
+
@wraps(f) # noqa: F821
|
|
984
|
+
async def wrapper(doc: S, *args, **kwargs) -> R | ProcessingError[S]:
|
|
985
|
+
try:
|
|
986
|
+
return await f(doc, *args, **kwargs)
|
|
987
|
+
except errors as e:
|
|
988
|
+
logger.exception("error while processing doc %s", doc)
|
|
989
|
+
report = exc_cls.from_exception(doc, e)
|
|
990
|
+
return report
|
|
991
|
+
else:
|
|
992
|
+
|
|
993
|
+
@wraps(f)
|
|
994
|
+
def wrapper(doc: ProcessedFile, *args, **kwargs) -> R | ProcessingError[S]:
|
|
995
|
+
|
|
996
|
+
try:
|
|
997
|
+
return f(doc, *args, **kwargs)
|
|
998
|
+
except errors as e:
|
|
999
|
+
logger.exception("error while processing doc %s", doc)
|
|
1000
|
+
report = exc_cls.from_exception(doc, e)
|
|
1001
|
+
return report
|
|
1002
|
+
|
|
1003
|
+
return wrapper
|
|
1004
|
+
|
|
1005
|
+
return parent_wrapper
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|