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.
Files changed (29) hide show
  1. {datashare_python-0.11.3 → datashare_python-0.12.0}/PKG-INFO +1 -1
  2. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/mimetypes_.py +4 -0
  3. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/objects.py +368 -109
  4. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/utils.py +219 -73
  5. datashare_python-0.12.0/datashare_python/worker-template.tar.gz +0 -0
  6. {datashare_python-0.11.3 → datashare_python-0.12.0}/pyproject.toml +1 -1
  7. datashare_python-0.11.3/datashare_python/worker-template.tar.gz +0 -0
  8. {datashare_python-0.11.3 → datashare_python-0.12.0}/.gitignore +0 -0
  9. {datashare_python-0.11.3 → datashare_python-0.12.0}/README.md +0 -0
  10. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/.gitignore +0 -0
  11. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/__init__.py +0 -0
  12. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/__main__.py +0 -0
  13. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/cli/__init__.py +0 -0
  14. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/cli/project.py +0 -0
  15. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/cli/task.py +0 -0
  16. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/cli/utils.py +0 -0
  17. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/cli/worker.py +0 -0
  18. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/config.py +0 -0
  19. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/conftest.py +0 -0
  20. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/constants.py +0 -0
  21. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/dependencies.py +0 -0
  22. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/discovery.py +0 -0
  23. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/exceptions.py +0 -0
  24. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/interceptors.py +0 -0
  25. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/logging_.py +0 -0
  26. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/task_client.py +0 -0
  27. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/template.py +0 -0
  28. {datashare_python-0.11.3 → datashare_python-0.12.0}/datashare_python/types_.py +0 -0
  29. {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.11.3
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
- 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)
@@ -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
- DocumentLocation,
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
- parts = (p for p in (doc_id[:2], doc_id[2:4]) if p)
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: ProcessedFile, paths: WorkerPaths
614
+ doc: Document, paths: WorkerPaths
611
615
  ) -> ProcessedFile:
612
- match doc.location:
613
- case DocumentLocation.ARTIFACTS:
614
- symlinks_dir = paths.workdir / doc.project / "symlinks"
615
- symlinks_dir.mkdir(parents=True, exist_ok=True)
616
- symlink_path = Path(*doc.path.parts[1:-1], doc.resource_name)
617
- symlink_path = symlinks_dir / symlink_path
618
- # Replace the "raw" with the resource name
619
- symlink_path.parent.mkdir(parents=True, exist_ok=True)
620
- artifact_path = paths.artifacts / doc.path
621
- with contextlib.suppress(FileExistsError):
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 _read_jsonl(path: Path) -> Iterable[dict]:
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](path: Path, cls: type[M]) -> Iterable[M]:
659
- return (cls.model_validate(d) for d in _read_jsonl(path))
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 processed_file_cls.model_validate_json(line)
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._eviction_callback)
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
- if self._eviction_callback is not None:
777
- for k, v in self._cache.items():
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
- value = self._cache.get(key, self._sentinel)
784
- if value is not self._sentinel:
785
- return value
786
- # Get the value first to be sure to return the right one in case of concurrent
787
- # access.
788
- #
789
- # Additionally, the factory can be long to complete (that's one of the reason
790
- # we want to cache its results). Because we don't lock the factory call,
791
- # together with the cache update, in case of concurrent access,
792
- # quicker factories will complete first and will stay longer in the cache.
793
- # This is fine, the alternative is to lock factory + cache update to avoid
794
- # concurrent access but this will mean waiting for the first factory call to
795
- # complete, this can potentially imply longer waits than no getting the value
796
- # from the cache
797
- value = default_factory()
798
- self._cache[key] = value
799
- return value
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
- value = self._cache.get(key, self._sentinel)
805
- if value is not self._sentinel:
806
- return value
807
- # Get the value first to be sure to return the right one in case of concurrent
808
- # access.
809
- #
810
- # Additionally, the factory can be long to complete (that's one of the reason
811
- # we want to cache its results). Because we don't lock the factory call,
812
- # together with the cache update, in case of concurrent access,
813
- # quicker factories will complete first and will stay longer in the cache.
814
- # This is fine, the alternative is to lock factory + cache update to avoid
815
- # concurrent access but this will mean waiting for the first factory call to
816
- # complete, this can potentially imply longer waits than no getting the value
817
- # from the cache
818
- value = await default_factory()
819
- self._cache[key] = value
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datashare-python"
3
- version = "0.11.3"
3
+ version = "0.12.0"
4
4
  description = "Manage Python tasks and local resources in Datashare"
5
5
  authors = [
6
6
  { name = "Clément Doumouro", email = "cdoumouro@icij.org" },