datashare-python 0.11.4__py3-none-any.whl → 0.12.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.
- datashare_python/mimetypes_.py +4 -0
- datashare_python/objects.py +368 -109
- datashare_python/utils.py +130 -34
- datashare_python/worker-template.tar.gz +0 -0
- {datashare_python-0.11.4.dist-info → datashare_python-0.12.0.dist-info}/METADATA +1 -1
- {datashare_python-0.11.4.dist-info → datashare_python-0.12.0.dist-info}/RECORD +8 -8
- {datashare_python-0.11.4.dist-info → datashare_python-0.12.0.dist-info}/WHEEL +0 -0
- {datashare_python-0.11.4.dist-info → datashare_python-0.12.0.dist-info}/entry_points.txt +0 -0
datashare_python/mimetypes_.py
CHANGED
|
@@ -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"},
|
datashare_python/objects.py
CHANGED
|
@@ -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)
|
datashare_python/utils.py
CHANGED
|
@@ -26,13 +26,13 @@ from functools import cache, wraps
|
|
|
26
26
|
from hashlib import sha256
|
|
27
27
|
from io import BytesIO
|
|
28
28
|
from pathlib import Path
|
|
29
|
-
from typing import Any, Self, TypeVar
|
|
29
|
+
from typing import Any, Protocol, Self, TypeVar
|
|
30
30
|
from uuid import uuid4
|
|
31
31
|
|
|
32
32
|
import temporalio
|
|
33
33
|
from aiofile import async_open
|
|
34
34
|
from lru import LRU
|
|
35
|
-
from pydantic import ValidationError
|
|
35
|
+
from pydantic import TypeAdapter, ValidationError
|
|
36
36
|
from pydantic.alias_generators import to_snake
|
|
37
37
|
from temporalio import activity, workflow
|
|
38
38
|
from temporalio.api.common.v1 import Payload
|
|
@@ -57,9 +57,13 @@ from .constants import MANIFEST_JSON, METADATA_JSON
|
|
|
57
57
|
from .mimetypes_ import types_map
|
|
58
58
|
from .objects import (
|
|
59
59
|
BaseModel,
|
|
60
|
+
DatashareFile,
|
|
60
61
|
DocArtifact,
|
|
61
|
-
|
|
62
|
+
Document,
|
|
63
|
+
ErrorSource,
|
|
62
64
|
ProcessedFile,
|
|
65
|
+
ProcessingError,
|
|
66
|
+
WorkerFile,
|
|
63
67
|
WorkerPaths,
|
|
64
68
|
)
|
|
65
69
|
from .types_ import RawAsyncProgressHandler
|
|
@@ -389,8 +393,7 @@ def to_scaled_async_progress(
|
|
|
389
393
|
def safe_dir(doc_id: str) -> Path:
|
|
390
394
|
if len(doc_id) < 4:
|
|
391
395
|
raise ValueError(f"expected doc_id to be at least 4, found {doc_id}")
|
|
392
|
-
|
|
393
|
-
return Path(*parts)
|
|
396
|
+
return Path(doc_id[:2], doc_id[2:4])
|
|
394
397
|
|
|
395
398
|
|
|
396
399
|
def artifacts_dir(doc_id: str, *, project: str) -> Path:
|
|
@@ -608,31 +611,18 @@ def activity_workdir(
|
|
|
608
611
|
|
|
609
612
|
|
|
610
613
|
def symlink_embedded_document_to_workdir(
|
|
611
|
-
doc:
|
|
614
|
+
doc: Document, paths: WorkerPaths
|
|
612
615
|
) -> ProcessedFile:
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
os.symlink(artifact_path, symlink_path)
|
|
624
|
-
return ProcessedFile(
|
|
625
|
-
path=symlink_path.relative_to(paths.workdir),
|
|
626
|
-
id=doc.id,
|
|
627
|
-
location=DocumentLocation.WORKDIR,
|
|
628
|
-
project=doc.project,
|
|
629
|
-
resource_name=doc.resource_name,
|
|
630
|
-
n_pages=doc.n_pages,
|
|
631
|
-
)
|
|
632
|
-
case DocumentLocation.FILESYSTEM:
|
|
633
|
-
return doc
|
|
634
|
-
case _:
|
|
635
|
-
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)
|
|
636
626
|
|
|
637
627
|
|
|
638
628
|
def artifact_path(
|
|
@@ -645,7 +635,7 @@ def artifact_path(
|
|
|
645
635
|
return root / artifacts_dir(doc_id, project=project) / artifact_type.filename
|
|
646
636
|
|
|
647
637
|
|
|
648
|
-
def
|
|
638
|
+
def read_jsonl(path: Path) -> Iterable[dict]:
|
|
649
639
|
with path.open() as f:
|
|
650
640
|
for line in f:
|
|
651
641
|
line = line.strip() # noqa: PLW2901
|
|
@@ -656,18 +646,82 @@ def _read_jsonl(path: Path) -> Iterable[dict]:
|
|
|
656
646
|
M = TypeVar("M", bound=BaseModel)
|
|
657
647
|
|
|
658
648
|
|
|
659
|
-
def read_jsonl_as[M](
|
|
660
|
-
|
|
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))
|
|
661
657
|
|
|
662
658
|
|
|
663
659
|
async def async_read_jsonl_as[M](
|
|
664
|
-
path: Path, processed_file_cls: type[M]
|
|
660
|
+
path: Path, processed_file_cls: type[M] | TypeAdapter[M]
|
|
665
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
|
|
666
666
|
async with async_open(path, "r") as f:
|
|
667
667
|
async for line in f:
|
|
668
668
|
line = line.strip() # noqa: PLW2901
|
|
669
669
|
if line:
|
|
670
|
-
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
|
|
671
725
|
|
|
672
726
|
|
|
673
727
|
@cache
|
|
@@ -690,6 +744,8 @@ def ext_to_mime_types(ext: str) -> set[str]:
|
|
|
690
744
|
return ext_to_mime_types(".xml")
|
|
691
745
|
case ".dclg.xml":
|
|
692
746
|
return ext_to_mime_types(".xml")
|
|
747
|
+
case ".msg":
|
|
748
|
+
return ext_to_mime_types(".eml")
|
|
693
749
|
try:
|
|
694
750
|
return types_map()[ext]
|
|
695
751
|
except KeyError as e:
|
|
@@ -907,3 +963,43 @@ def config_cache_key(config: BaseModel) -> str:
|
|
|
907
963
|
.replace("_", "-")
|
|
908
964
|
)
|
|
909
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
|
|
@@ -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/
|
|
@@ -9,20 +9,20 @@ datashare_python/discovery.py,sha256=3mUck2GUSKu2DSP9-MM1OdVM4FqFKqfBeSQDQZxgh2g
|
|
|
9
9
|
datashare_python/exceptions.py,sha256=bVHEAXxDPKfxeeMC0hJXEsrJkgsKO2ESAhxWU96GA4M,496
|
|
10
10
|
datashare_python/interceptors.py,sha256=-XC-y_MvGyHlZQ6jItfMRPAmKwQkQzzncpAGxceKlwM,16485
|
|
11
11
|
datashare_python/logging_.py,sha256=96KNXhP-JR5ybu9dld4VLtF7v7PfiphOCR1Nio-mmUs,4483
|
|
12
|
-
datashare_python/mimetypes_.py,sha256=
|
|
13
|
-
datashare_python/objects.py,sha256=
|
|
12
|
+
datashare_python/mimetypes_.py,sha256=YAtNKewLJIXuTxdYYkprK237IOOw1vUGUVNiyZRdpQU,49874
|
|
13
|
+
datashare_python/objects.py,sha256=Fy9LVFMTSWsgjJBhZyJrwnWFP0PKcsw3arJ8JwpumGc,26878
|
|
14
14
|
datashare_python/task_client.py,sha256=oTmP8bvZW0UyhLNMi1AV3XIAx7hrdbxNRss2Mw2azEc,8435
|
|
15
15
|
datashare_python/template.py,sha256=N1uM8TRXh5tQPV7BC1oJj-Pdm2Gs_SkjaXv50RnNYso,4682
|
|
16
16
|
datashare_python/types_.py,sha256=cPcCkIk6ecILrKetPnAsrb0njHYuWJU3eZicUXy2lF8,1278
|
|
17
|
-
datashare_python/utils.py,sha256=
|
|
18
|
-
datashare_python/worker-template.tar.gz,sha256=
|
|
17
|
+
datashare_python/utils.py,sha256=kiFJgGfGzk5BWugbhjQHONRqk8UVt8eJgKWW2xwbYrg,33231
|
|
18
|
+
datashare_python/worker-template.tar.gz,sha256=Y5b8wrVSBT43PUMq1EzWEj8Off12htSb3TuXbZO-fiY,236089
|
|
19
19
|
datashare_python/worker.py,sha256=ad36_yJOgwtj8KBheWndGkqaP_2aXTPSrYaHrbCXTvg,8389
|
|
20
20
|
datashare_python/cli/__init__.py,sha256=9BPWtssDgsVfWMsZ1TtZCla0EC_kai4RHttr8oNLYOE,1401
|
|
21
21
|
datashare_python/cli/project.py,sha256=w32Gy9AOL5B00uDT4in7YUCt2g68FnNbvwg2M3a8G6o,946
|
|
22
22
|
datashare_python/cli/task.py,sha256=GsyR5o0fN4Pd8tJKAUgivAvCBKT3L4Ay5j_BCW1XBHk,5828
|
|
23
23
|
datashare_python/cli/utils.py,sha256=p69CQb0zfixuyBkiZprhdMCc_NuYwXyAn6vC9H1UzAw,911
|
|
24
24
|
datashare_python/cli/worker.py,sha256=x0p3a1_NsZPbmV7-WSVMRyW3_iadYcgyZKbDvWgymQc,5790
|
|
25
|
-
datashare_python-0.
|
|
26
|
-
datashare_python-0.
|
|
27
|
-
datashare_python-0.
|
|
28
|
-
datashare_python-0.
|
|
25
|
+
datashare_python-0.12.0.dist-info/METADATA,sha256=IFM7XeiL67zFPkHxn8oUIPUDwX4KksqSjeMx4sqil4M,1012
|
|
26
|
+
datashare_python-0.12.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
27
|
+
datashare_python-0.12.0.dist-info/entry_points.txt,sha256=ILE7auxabHWiu3GC-AunWnzjhOI_SbZp7D4GqZHlLw4,68
|
|
28
|
+
datashare_python-0.12.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|