datashare-python 0.11.4__py3-none-any.whl → 0.12.1__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.
@@ -1,5 +1,6 @@
1
+ from datetime import timedelta
1
2
  from enum import StrEnum
2
- from typing import Literal
3
+ from typing import TYPE_CHECKING, Literal
3
4
 
4
5
  from icij_common.es import ESClient
5
6
  from icij_common.pydantic_utils import ICIJSettings
@@ -12,7 +13,9 @@ import datashare_python
12
13
  from .objects import BaseModel, WorkerPaths
13
14
  from .task_client import DatashareTaskClient
14
15
  from .types_ import TemporalClient
15
- from .utils import PYDANTIC_DATA_CONVERTER, SharedResources, close_cm_callback
16
+
17
+ if TYPE_CHECKING:
18
+ from .utils import SharedResources
16
19
 
17
20
  _ALL_LOGGERS = [datashare_python.__name__]
18
21
 
@@ -63,6 +66,8 @@ class TemporalClientConfig(BaseModel):
63
66
  _client: TemporalClient | None = PrivateAttr(default=None)
64
67
 
65
68
  async def to_client(self) -> TemporalClient:
69
+ from .utils import PYDANTIC_DATA_CONVERTER # noqa: PLC0415
70
+
66
71
  if self._client is None:
67
72
  runtime = None
68
73
  if self.prometheus_host is not None:
@@ -70,6 +75,7 @@ class TemporalClientConfig(BaseModel):
70
75
  metrics=PrometheusConfig(bind_address="0.0.0.0:9000")
71
76
  )
72
77
  runtime = Runtime(telemetry=telemetry_config)
78
+
73
79
  self._client = await TemporalClient.connect(
74
80
  target_host=self.host,
75
81
  namespace=self.namespace,
@@ -97,7 +103,9 @@ class ResourceCacheConfig(BaseModel):
97
103
  size: int = 1
98
104
  exit_context_managers: bool = True
99
105
 
100
- def to_resource_cache(self) -> SharedResources:
106
+ def to_resource_cache(self) -> "SharedResources":
107
+ from .utils import SharedResources, close_cm_callback # noqa: PLC0415
108
+
101
109
  eviction_callback = None
102
110
  if self.exit_context_managers:
103
111
  eviction_callback = close_cm_callback
@@ -134,3 +142,8 @@ class WorkerConfig(ICIJSettings, BaseModel):
134
142
 
135
143
  async def to_temporal_client(self) -> TemporalClient:
136
144
  return await self.temporal.to_client()
145
+
146
+
147
+ class ActivityTimeouts(BaseModel):
148
+ start_to_close: timedelta | None = None
149
+ heartbeat: timedelta = timedelta(minutes=1)
@@ -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
- from abc import ABC
6
- from collections.abc import Awaitable, Callable
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
- T = TypeVar("T")
63
- Predicate = Callable[[T], bool] | Callable[[T], Awaitable[bool]]
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 DocumentLocation(StrEnum):
125
+ class FileLocation(StrEnum):
129
126
  FILESYSTEM = "filesystem"
130
127
  ARTIFACTS = "artifacts"
131
128
  WORKDIR = "workdir"
132
129
 
133
130
 
134
- def _is_relative(value: Path) -> Path:
135
- if value.is_absolute():
136
- raise ValueError(
137
- f"FilesystemDocument path should always be relative, found {value}"
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, paths: WorkerPaths) -> Path:
167
- from datashare_python.utils import artifacts_dir # noqa: PLC0415
168
-
169
- match self.location:
170
- case DocumentLocation.FILESYSTEM:
171
- return paths.filesystem / self.path
172
- case DocumentLocation.ARTIFACTS:
173
- project = self.project
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: {self.path}")
145
+ raise ValueError(f"invalid location: {path}")
179
146
 
180
147
 
181
- class ProcessedPage(ProcessedFile):
182
- page_number: int
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
- def to_processed_file(self) -> ProcessedFile:
299
- from .utils import artifacts_dir # noqa: PLC0415
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
- # Backward compat for project before resourcename was mandatory
308
- if self.is_root_document:
309
- resource_name = self.path.name
310
- else:
311
- msg = (
312
- f"doc {self} can't turn ES document into a"
313
- f" {ProcessedFile.__name__} without tika metadata resources name"
314
- f" in metadata: {self.metadata}"
315
- )
316
- raise ValueError(msg)
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
- def to_route(self) -> DocRoute:
347
- if self.root_document is not None:
348
- return self.root_document, self.id
349
- return self.id, self.id
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
@@ -21,18 +21,17 @@ from collections.abc import (
21
21
  )
22
22
  from copy import deepcopy
23
23
  from dataclasses import dataclass
24
- from datetime import timedelta
25
24
  from functools import cache, wraps
26
25
  from hashlib import sha256
27
26
  from io import BytesIO
28
27
  from pathlib import Path
29
- from typing import Any, Self, TypeVar
28
+ from typing import Any, Protocol, Self, TypeVar
30
29
  from uuid import uuid4
31
30
 
32
31
  import temporalio
33
32
  from aiofile import async_open
34
33
  from lru import LRU
35
- from pydantic import ValidationError
34
+ from pydantic import TypeAdapter, ValidationError
36
35
  from pydantic.alias_generators import to_snake
37
36
  from temporalio import activity, workflow
38
37
  from temporalio.api.common.v1 import Payload
@@ -47,6 +46,7 @@ from temporalio.converter import (
47
46
  )
48
47
  from temporalio.exceptions import ApplicationError
49
48
 
49
+ from datashare_python.config import ActivityTimeouts
50
50
  from datashare_python.types_ import (
51
51
  AsyncProgressRateHandler,
52
52
  RawSyncProgressHandler,
@@ -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
- DocumentLocation,
62
+ Document,
63
+ ErrorSource,
62
64
  ProcessedFile,
65
+ ProcessingError,
66
+ WorkerFile,
63
67
  WorkerPaths,
64
68
  )
65
69
  from .types_ import RawAsyncProgressHandler
@@ -162,10 +166,11 @@ async def execute_activity(
162
166
  arg: Any = temporalio.common._arg_unset,
163
167
  *,
164
168
  args: list | None = None,
165
- start_to_close_timeout: timedelta | None = None,
166
- heartbeat_timeout: timedelta = timedelta(minutes=1),
169
+ timeouts: ActivityTimeouts | None = None,
167
170
  retry_policy: temporalio.common.RetryPolicy | None = None,
168
171
  ) -> Any:
172
+ if timeouts is None:
173
+ timeouts = ActivityTimeouts()
169
174
  if args is None:
170
175
  args = []
171
176
  retry_policy = _retry_policy_with_default(retry_policy)
@@ -173,10 +178,10 @@ async def execute_activity(
173
178
  activity,
174
179
  arg=arg,
175
180
  args=args,
176
- start_to_close_timeout=start_to_close_timeout,
177
181
  task_queue=task_queue,
178
182
  retry_policy=retry_policy,
179
- heartbeat_timeout=heartbeat_timeout,
183
+ start_to_close_timeout=timeouts.start_to_close,
184
+ heartbeat_timeout=timeouts.heartbeat,
180
185
  )
181
186
 
182
187
 
@@ -389,8 +394,7 @@ def to_scaled_async_progress(
389
394
  def safe_dir(doc_id: str) -> Path:
390
395
  if len(doc_id) < 4:
391
396
  raise ValueError(f"expected doc_id to be at least 4, found {doc_id}")
392
- parts = (p for p in (doc_id[:2], doc_id[2:4]) if p)
393
- return Path(*parts)
397
+ return Path(doc_id[:2], doc_id[2:4])
394
398
 
395
399
 
396
400
  def artifacts_dir(doc_id: str, *, project: str) -> Path:
@@ -608,31 +612,18 @@ def activity_workdir(
608
612
 
609
613
 
610
614
  def symlink_embedded_document_to_workdir(
611
- doc: ProcessedFile, paths: WorkerPaths
615
+ doc: Document, paths: WorkerPaths
612
616
  ) -> ProcessedFile:
613
- match doc.location:
614
- case DocumentLocation.ARTIFACTS:
615
- symlinks_dir = paths.workdir / doc.project / "symlinks"
616
- symlinks_dir.mkdir(parents=True, exist_ok=True)
617
- symlink_path = Path(*doc.path.parts[1:-1], doc.resource_name)
618
- symlink_path = symlinks_dir / symlink_path
619
- # Replace the "raw" with the resource name
620
- symlink_path.parent.mkdir(parents=True, exist_ok=True)
621
- artifact_path = paths.artifacts / doc.path
622
- with contextlib.suppress(FileExistsError):
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}")
617
+ ds_file = DatashareFile.from_parent(doc)
618
+ if doc.is_root_document:
619
+ return ds_file
620
+ symlinks_dir = paths.workdir / doc.project / "symlinks"
621
+ symlink_path = symlinks_dir / safe_dir(doc.id) / doc.id / doc.resource_name
622
+ symlink_path.parent.mkdir(parents=True, exist_ok=True)
623
+ raw_path = ds_file.locate(paths)
624
+ with contextlib.suppress(FileExistsError):
625
+ os.symlink(raw_path, symlink_path)
626
+ return WorkerFile.from_parent(ds_file, symlink_path, paths)
636
627
 
637
628
 
638
629
  def artifact_path(
@@ -645,7 +636,7 @@ def artifact_path(
645
636
  return root / artifacts_dir(doc_id, project=project) / artifact_type.filename
646
637
 
647
638
 
648
- def _read_jsonl(path: Path) -> Iterable[dict]:
639
+ def read_jsonl(path: Path) -> Iterable[dict]:
649
640
  with path.open() as f:
650
641
  for line in f:
651
642
  line = line.strip() # noqa: PLW2901
@@ -656,18 +647,82 @@ def _read_jsonl(path: Path) -> Iterable[dict]:
656
647
  M = TypeVar("M", bound=BaseModel)
657
648
 
658
649
 
659
- def read_jsonl_as[M](path: Path, cls: type[M]) -> Iterable[M]:
660
- return (cls.model_validate(d) for d in _read_jsonl(path))
650
+ def read_jsonl_as[M](
651
+ path: Path, processed_file_cls: type[M] | TypeAdapter[M]
652
+ ) -> Iterable[M]:
653
+ if isinstance(processed_file_cls, TypeAdapter):
654
+ deser_fn = processed_file_cls.validate_python
655
+ else:
656
+ deser_fn = processed_file_cls.model_validate
657
+ return (deser_fn(d) for d in read_jsonl(path))
661
658
 
662
659
 
663
660
  async def async_read_jsonl_as[M](
664
- path: Path, processed_file_cls: type[M]
661
+ path: Path, processed_file_cls: type[M] | TypeAdapter[M]
665
662
  ) -> AsyncIterable[M]:
663
+ if isinstance(processed_file_cls, TypeAdapter):
664
+ deser_fn = processed_file_cls.validate_json
665
+ else:
666
+ deser_fn = processed_file_cls.model_validate_json
666
667
  async with async_open(path, "r") as f:
667
668
  async for line in f:
668
669
  line = line.strip() # noqa: PLW2901
669
670
  if line:
670
- yield processed_file_cls.model_validate_json(line)
671
+ yield deser_fn(line)
672
+
673
+
674
+ async def write_batches(
675
+ batches: AsyncIterable[list[ProcessedFile]] | Iterable[list[ProcessedFile]],
676
+ root: Path,
677
+ batch_offset: int = 0,
678
+ prefix: str = "batch_",
679
+ ) -> AsyncIterable[Path]:
680
+ if hasattr(batches, "__aiter__"):
681
+ async for b in _async_write_batches(batches, root, batch_offset, prefix):
682
+ yield b
683
+ return
684
+ async for b in _write_batches(batches, root, batch_offset, prefix):
685
+ yield b
686
+
687
+
688
+ async def _async_write_batches(
689
+ batches: AsyncIterable[list[ProcessedFile]],
690
+ root: Path,
691
+ batch_offset: int,
692
+ prefix: str,
693
+ ) -> AsyncIterable[Path]:
694
+ batch_id = batch_offset
695
+ async for batch in batches:
696
+ batch_path = root / f"{batch_id // 1000}" / f"{prefix}{batch_id}.jsonl"
697
+ batch_path.parent.mkdir(parents=True, exist_ok=True)
698
+ async with async_open(batch_path, "w") as f:
699
+ for fs_doc in batch:
700
+ serialized = fs_doc.model_dump_json(
701
+ exclude_none=True, polymorphic_serialization=True
702
+ )
703
+ await f.write(f"{serialized}\n")
704
+ yield batch_path
705
+ batch_id += 1
706
+
707
+
708
+ async def _write_batches(
709
+ batches: Iterable[list[ProcessedFile]],
710
+ root: Path,
711
+ batch_offset: int,
712
+ prefix: str,
713
+ ) -> AsyncIterable[Path]:
714
+ batch_id = batch_offset
715
+ for batch in batches:
716
+ batch_path = root / f"{batch_id // 1000}" / f"{prefix}{batch_id}.jsonl"
717
+ batch_path.parent.mkdir(parents=True, exist_ok=True)
718
+ async with async_open(batch_path, "w") as f:
719
+ for fs_doc in batch:
720
+ serialized = fs_doc.model_dump_json(
721
+ exclude_none=True, polymorphic_serialization=True
722
+ )
723
+ await f.write(f"{serialized}\n")
724
+ yield batch_path
725
+ batch_id += 1
671
726
 
672
727
 
673
728
  @cache
@@ -690,6 +745,8 @@ def ext_to_mime_types(ext: str) -> set[str]:
690
745
  return ext_to_mime_types(".xml")
691
746
  case ".dclg.xml":
692
747
  return ext_to_mime_types(".xml")
748
+ case ".msg":
749
+ return ext_to_mime_types(".eml")
693
750
  try:
694
751
  return types_map()[ext]
695
752
  except KeyError as e:
@@ -907,3 +964,43 @@ def config_cache_key(config: BaseModel) -> str:
907
964
  .replace("_", "-")
908
965
  )
909
966
  return f"{config_label}-{str(hash(config))}"
967
+
968
+
969
+ class _PreprocessingFunction[S: ErrorSource, R](Protocol):
970
+ def __call__(self, doc: S, *args, **kwargs) -> R: ...
971
+
972
+
973
+ def reports_errors[S: ErrorSource, R](
974
+ errors: tuple[type[Exception]], exc_cls: type[ProcessingError[S]]
975
+ ) -> Callable[
976
+ [_PreprocessingFunction[S, R]], _PreprocessingFunction[S, R | ProcessingError[S]]
977
+ ]:
978
+
979
+ def parent_wrapper(
980
+ f: _PreprocessingFunction[S, R],
981
+ ) -> _PreprocessingFunction[S, R | ProcessingError[S]]:
982
+ if inspect.iscoroutinefunction(f):
983
+
984
+ @wraps(f) # noqa: F821
985
+ async def wrapper(doc: S, *args, **kwargs) -> R | ProcessingError[S]:
986
+ try:
987
+ return await f(doc, *args, **kwargs)
988
+ except errors as e:
989
+ logger.exception("error while processing doc %s", doc)
990
+ report = exc_cls.from_exception(doc, e)
991
+ return report
992
+ else:
993
+
994
+ @wraps(f)
995
+ def wrapper(doc: ProcessedFile, *args, **kwargs) -> R | ProcessingError[S]:
996
+
997
+ try:
998
+ return f(doc, *args, **kwargs)
999
+ except errors as e:
1000
+ logger.exception("error while processing doc %s", doc)
1001
+ report = exc_cls.from_exception(doc, e)
1002
+ return report
1003
+
1004
+ return wrapper
1005
+
1006
+ return parent_wrapper
Binary file
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: datashare-python
3
- Version: 0.11.4
3
+ Version: 0.12.1
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/
@@ -1,7 +1,7 @@
1
1
  datashare_python/.gitignore,sha256=e-SRgnvGGdsjRrqgKsTzALz6Obx8IYiOjr0yaAxT6v8,22
2
2
  datashare_python/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  datashare_python/__main__.py,sha256=g-fvS46zl9umKmGrSpl-OG-8PSuZgjqvTCqjpsZtSps,101
4
- datashare_python/config.py,sha256=YybzH9QoILIMbUKGPgbgZj_ZwLFQqCRQ3mq4YSD6NWU,4158
4
+ datashare_python/config.py,sha256=ODJrwrOTZK9D90cUbNYqSUnu5vsyMWw_IVgXpSdDYJs,4464
5
5
  datashare_python/conftest.py,sha256=hgBfg5Df9Kf0tMPvrqsrgjR6LZx1jG8WsZiVLQLHhuI,13824
6
6
  datashare_python/constants.py,sha256=-Irwd2bbnFGIkLVJi_SiQ7PM2eNW3Ia_co4SE6z4j-A,352
7
7
  datashare_python/dependencies.py,sha256=Q-BIhCwWu-GGp9omYNhENZ88cF0QJ8spfaMZX40mmNU,4386
@@ -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=mjQgaqnSFZEcl3LoLDGBaBBjTTzwYfSNZND9Fmkk2zs,49682
13
- datashare_python/objects.py,sha256=RuDEC4dZPndMYwFKhmMlToY8jjwjupG7mGGCG5tzc6g,18788
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=MU_laix9TclXQ7Pt0mqqYQylYR3OKU-MItjQupcG3c8,30183
18
- datashare_python/worker-template.tar.gz,sha256=5U1vAMg7xtJSKAz20kqz7QLknvzmREN5ddqK7RvAYyI,236093
17
+ datashare_python/utils.py,sha256=Y0rNipGGW4yhOK6GrhXP9cdmHWMis2oFUxRsNK9eBMU,33254
18
+ datashare_python/worker-template.tar.gz,sha256=A0dXeN0Y4zZghAltznv5SvhXjpOdsWJDU1wny87uUlY,281066
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.11.4.dist-info/METADATA,sha256=wesn34A1Y6Omeo3gftg-Jd4jiXo4a4hktN1blM3vCJI,1012
26
- datashare_python-0.11.4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
27
- datashare_python-0.11.4.dist-info/entry_points.txt,sha256=ILE7auxabHWiu3GC-AunWnzjhOI_SbZp7D4GqZHlLw4,68
28
- datashare_python-0.11.4.dist-info/RECORD,,
25
+ datashare_python-0.12.1.dist-info/METADATA,sha256=FiCE6tiVkAW0xz8za0fBqlE_VttoPwOxEhsnRk2-Mxk,1012
26
+ datashare_python-0.12.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
27
+ datashare_python-0.12.1.dist-info/entry_points.txt,sha256=ILE7auxabHWiu3GC-AunWnzjhOI_SbZp7D4GqZHlLw4,68
28
+ datashare_python-0.12.1.dist-info/RECORD,,