datashare-python 0.9.8__py3-none-any.whl → 0.10.0.dev0__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,22 +1,22 @@
1
1
  import logging
2
2
  import os
3
- from asyncio import Lock
3
+ from abc import ABC
4
4
  from collections.abc import Awaitable, Callable
5
- from dataclasses import InitVar, dataclass, field
5
+ from dataclasses import dataclass
6
6
  from datetime import UTC, datetime
7
7
  from enum import StrEnum, unique
8
8
  from io import BytesIO
9
9
  from pathlib import Path
10
- from typing import Annotated, Any, ClassVar, Literal, Self, TypeVar, cast
10
+ from typing import Annotated, Any, ClassVar, Generic, Literal, Self, TypeVar, cast
11
11
 
12
12
  import langcodes
13
- from lru import LRU
13
+ from icij_common.registrable import Registrable
14
14
  from pydantic_core import PydanticCustomError, ValidationError, core_schema
15
15
  from pydantic_core.core_schema import PlainValidatorFunctionSchema
16
16
  from pydantic_extra_types.language_code import LanguageName
17
17
  from temporalio import workflow
18
18
 
19
- from .constants import DEFAULT_SHARED_RESOURCES_SIZE, TIKA_METADATA_RESOURCENAME
19
+ from .constants import TIKA_METADATA_RESOURCENAME
20
20
 
21
21
  with workflow.unsafe.imports_passed_through():
22
22
  from icij_common.es import (
@@ -34,12 +34,17 @@ with workflow.unsafe.imports_passed_through():
34
34
  from icij_common.pydantic_utils import (
35
35
  icij_config,
36
36
  lowercamel_case_config,
37
+ make_enum_discriminator,
37
38
  merge_configs,
38
39
  no_enum_values_config,
40
+ tagged_union,
39
41
  )
40
42
  from pydantic import (
41
43
  AfterValidator,
44
+ AliasChoices,
42
45
  BeforeValidator,
46
+ ConfigDict,
47
+ Discriminator,
43
48
  Field,
44
49
  GetCoreSchemaHandler,
45
50
  TypeAdapter,
@@ -104,9 +109,15 @@ class DatashareLanguage(str):
104
109
  return self.as_language_name.alpha3
105
110
 
106
111
 
112
+ class WorkerPaths(BaseModel):
113
+ filesystem: Path
114
+ artifacts: Path
115
+ workdir: Path
116
+
117
+
107
118
  @unique
108
119
  class DocumentLocation(StrEnum):
109
- ORIGINAL = "original"
120
+ FILESYSTEM = "filesystem"
110
121
  ARTIFACTS = "artifacts"
111
122
  WORKDIR = "workdir"
112
123
 
@@ -119,30 +130,49 @@ def _is_relative(value: Path) -> Path:
119
130
  return value
120
131
 
121
132
 
122
- class FilesystemDocument(DatashareModel):
133
+ class ProcessedFile(BaseModel):
123
134
  id: str
124
135
  path: Annotated[Path, AfterValidator(_is_relative)]
125
- index: str
136
+ project: str
126
137
  location: DocumentLocation
127
138
  resource_name: str
139
+ n_pages: int
140
+ parent: "ProcessedFile | None" = None
141
+
142
+ @classmethod
143
+ def from_doc(cls, doc: "Document") -> Self:
144
+ return doc.to_processed_file()
145
+
146
+ def child(self, path: Path, paths: WorkerPaths) -> Self:
147
+ return ProcessedFile(
148
+ id=self.id,
149
+ path=path.relative_to(paths.workdir),
150
+ project=self.project,
151
+ location=DocumentLocation.WORKDIR,
152
+ resource_name=path.name,
153
+ n_pages=self.n_pages,
154
+ parent=self,
155
+ )
128
156
 
129
- def locate(
130
- self, original_root: Path, *, artifacts_root: Path, workdir: Path
131
- ) -> Path:
157
+ def locate(self, paths: WorkerPaths) -> Path:
132
158
  from datashare_python.utils import artifacts_dir # noqa: PLC0415
133
159
 
134
160
  match self.location:
135
- case DocumentLocation.ORIGINAL:
136
- return original_root / self.path
161
+ case DocumentLocation.FILESYSTEM:
162
+ return paths.filesystem / self.path
137
163
  case DocumentLocation.ARTIFACTS:
138
- project = self.index
139
- return artifacts_root / artifacts_dir(self.id, project=project) / "raw"
164
+ project = self.project
165
+ return paths.artifacts / artifacts_dir(self.id, project=project) / "raw"
140
166
  case DocumentLocation.WORKDIR:
141
- return workdir / self.path
167
+ return paths.workdir / self.path
142
168
  case _:
143
169
  raise ValueError(f"invalid location: {self.path}")
144
170
 
145
171
 
172
+ class ProcessedPage(ProcessedFile):
173
+ page_number: int
174
+
175
+
146
176
  class IETFLanguage(str):
147
177
  @classmethod
148
178
  def __get_pydantic_core_schema__(
@@ -174,6 +204,11 @@ class Translation(BaseModel): # No camelcase here we don't know why
174
204
  content: Annotated[str, BeforeValidator(_from_sentences)]
175
205
 
176
206
 
207
+ Routing = str
208
+ DocID = str
209
+ DocRoute = tuple[DocID, Routing]
210
+
211
+
177
212
  class Document(DatashareModel):
178
213
  id: str
179
214
  language: DatashareLanguage
@@ -217,17 +252,17 @@ class Document(DatashareModel):
217
252
  metadata=sources.get(DOC_METADATA),
218
253
  )
219
254
 
220
- def to_filesystem(self) -> FilesystemDocument:
255
+ def to_processed_file(self) -> ProcessedFile:
221
256
  from .utils import artifacts_dir # noqa: PLC0415
222
257
 
223
258
  if self.metadata is None:
224
259
  raise ValueError(
225
- "can't compute filesyste path for document withtout metadata"
260
+ "can't compute filesystem path for document withtout metadata"
226
261
  )
227
262
  resource_name = cast(str, self.metadata[TIKA_METADATA_RESOURCENAME])
228
263
  if self.root_document is None:
229
264
  path = self.path
230
- location = DocumentLocation.ORIGINAL
265
+ location = DocumentLocation.FILESYSTEM
231
266
  else:
232
267
  if self.index is None:
233
268
  msg = (
@@ -241,14 +276,23 @@ class Document(DatashareModel):
241
276
  # we store a relative path otherwise joining with the location will fail
242
277
  if path.parts and path.parts[0] == os.path.sep:
243
278
  path = Path(*path.parts[1:])
244
- return FilesystemDocument(
279
+ n_pages = 1
280
+ if self.metadata:
281
+ n_pages = self.metadata.get("tika_metadata_xmptpg_npages", n_pages)
282
+ return ProcessedFile(
245
283
  id=self.id,
246
284
  path=path,
247
- index=self.index,
285
+ project=self.index,
248
286
  location=location,
249
287
  resource_name=resource_name,
288
+ n_pages=n_pages,
250
289
  )
251
290
 
291
+ def to_route(self) -> DocRoute:
292
+ if self.root_document is not None:
293
+ return self.root_document, self.id
294
+ return self.id, self.id
295
+
252
296
 
253
297
  def _is_absolute_path(v: bytes | BytesIO | Path) -> Any:
254
298
  if isinstance(v, Path) and not v.is_absolute():
@@ -256,13 +300,142 @@ def _is_absolute_path(v: bytes | BytesIO | Path) -> Any:
256
300
  return v
257
301
 
258
302
 
259
- @dataclass(frozen=True)
260
- class DocArtifact:
303
+ class ArtifactType(StrEnum):
304
+ STRUCTURE = "structure"
305
+ ASR_TRANSCRIPTION = "transcription"
306
+ PASSPORTS = "passports"
307
+
308
+
309
+ class ManifestEntryStatus(StrEnum):
310
+ COMPLETE = "complete"
311
+ PARTIAL = "partial"
312
+
313
+
314
+ class TaskArgs(DatashareModel, ABC):
315
+ def as_manifest_task_input(self) -> dict[str, Any]:
316
+ # This is a base implementation, if the input is too large to be dumped,
317
+ # override this and pop large keys
318
+ # Dump in json mode to make testing easier
319
+ as_manifest = self.model_dump(by_alias=True, mode="json")
320
+ return as_manifest
321
+
322
+
323
+ A = TypeVar("A", bound=TaskArgs)
324
+
325
+
326
+ class ManifestEntry(DatashareModel, Generic[A], ABC):
327
+ status: ManifestEntryStatus
328
+ label: str | None = None
329
+ input: Annotated[
330
+ dict[str, Any] | None,
331
+ Field(
332
+ validation_alias=AliasChoices("taskInput", "input"),
333
+ serialization_alias="taskInput",
334
+ ),
335
+ ]
336
+
337
+ @classmethod
338
+ def complete(cls, args: A, label: str | None = None, **kwargs) -> Self:
339
+ return cls(
340
+ input=args.as_manifest_task_input(),
341
+ label=label,
342
+ status=ManifestEntryStatus.COMPLETE,
343
+ **kwargs,
344
+ )
345
+
346
+ @classmethod
347
+ def partial(cls, args: A, label: str | None = None, **kwargs) -> Self:
348
+ return cls(
349
+ input=args.as_manifest_task_input(),
350
+ label=label,
351
+ status=ManifestEntryStatus.PARTIAL,
352
+ **kwargs,
353
+ )
354
+
355
+
356
+ class PaginationType(StrEnum):
357
+ FILESYSTEM = "filesystem"
358
+ BYTE_RANGES = "byteRanges"
359
+
360
+
361
+ def _validate_pages_range(v: Any) -> None:
362
+ if not isinstance(v, list):
363
+ msg = f"expected a list, got {type(v)}"
364
+ raise TypeError(msg)
365
+ previous_end = None
366
+ for page_i, (start, end) in enumerate(v):
367
+ if not start <= end:
368
+ msg = "end of page must be >= start"
369
+ raise ValueError(msg)
370
+ if previous_end is not None and previous_end != start:
371
+ msg = (
372
+ f"start of page {page_i} doesn't match end of previous "
373
+ f"page {previous_end}"
374
+ )
375
+ raise ValueError(msg)
376
+ return v
377
+
378
+
379
+ PagesRange = Annotated[list[tuple[int, int]], AfterValidator(_validate_pages_range)]
380
+
381
+
382
+ class BasePagination(Registrable, DatashareModel):
383
+ registry_key: ClassVar[str] = Field(frozen=True, default="type")
384
+ type: ClassVar[PaginationType] = Field(frozen=True)
385
+
386
+
387
+ @BasePagination.register(PaginationType.FILESYSTEM)
388
+ class FilesystemPagination(BasePagination):
389
+ type: ClassVar[PaginationType] = Field(
390
+ default=PaginationType.FILESYSTEM, frozen=True
391
+ )
392
+
393
+
394
+ @BasePagination.register(PaginationType.BYTE_RANGES)
395
+ class ByteRangesPagination(BasePagination):
396
+ type: ClassVar[PaginationType] = Field(
397
+ default=PaginationType.BYTE_RANGES, frozen=True
398
+ )
399
+ byte_ranges: PagesRange
400
+
401
+
402
+ pagination_discriminator = make_enum_discriminator("type", PaginationType)
403
+ Pagination = Annotated[
404
+ tagged_union(BasePagination.__subclasses__(), lambda x: x.type.default),
405
+ Discriminator(pagination_discriminator),
406
+ ]
407
+
408
+
409
+ class Pages(DatashareModel):
410
+ total: int
411
+ pagination: Pagination
412
+
413
+ @model_validator(mode="after")
414
+ def byte_ranges_length_should_match_total(self) -> Self:
415
+ if (
416
+ isinstance(self.pagination, ByteRangesPagination)
417
+ and len(self.pagination.byte_ranges) != self.total
418
+ ):
419
+ n_pages = len(self.pagination.byte_ranges)
420
+ msg = (
421
+ f"byte_ranges must match total. Found {n_pages} for"
422
+ f" byte_ranges and {self.total} for total."
423
+ )
424
+ raise ValueError(msg)
425
+ return self
426
+
427
+
428
+ class DocArtifact(DatashareModel, ABC):
429
+ # This object is not used for serde, just as a container, it's OK to allow
430
+ # arbitrary types (to allow storing BytesIO)
431
+ model_config = ConfigDict(arbitrary_types_allowed=True)
432
+
261
433
  project: str
262
434
  doc_id: str
263
435
  artifact: Annotated[bytes | BytesIO | Path, AfterValidator(_is_absolute_path)]
264
- filename: str
265
- metadata_key: str
436
+ filename: ClassVar[str] # Override this
437
+ type: ClassVar[ArtifactType] # Override this
438
+ manifest_entry: ManifestEntry
266
439
 
267
440
 
268
441
  @unique
@@ -369,47 +542,3 @@ class TaskGroup:
369
542
  @classmethod
370
543
  def python(cls) -> Self:
371
544
  return cls(name="PYTHON")
372
-
373
-
374
- @dataclass(frozen=True)
375
- class Shared:
376
- cache_size: InitVar[int] = DEFAULT_SHARED_RESOURCES_SIZE
377
- eviction_callback: InitVar[Callable] = None
378
- _resources: LRU = field(init=False, repr=False)
379
- _lock: Lock = field(init=False, repr=False)
380
-
381
- def __post_init__(self, cache_size: int, eviction_callback: Callable) -> None:
382
- object.__setattr__(
383
- self, "_resources", LRU(cache_size, callback=eviction_callback)
384
- )
385
- object.__setattr__(self, "_lock", Lock())
386
-
387
- def get_resource(
388
- self, key: str, default: Any = None, *, set_if_unavailable: bool = True
389
- ) -> Any:
390
- if key not in self._resources and set_if_unavailable:
391
- self.set_resource(key, default)
392
-
393
- return self._resources.get(key, default)
394
-
395
- def set_resource(self, key: str, value: Any) -> None:
396
- self._resources[key] = value
397
-
398
- def pop_resource(self, key: str, default: Any = None) -> Any:
399
- return self._resources.pop(key, default)
400
-
401
- async def async_get_resource(
402
- self, key: str, default: Any = None, *, set_if_unavailable: bool = True
403
- ) -> Any:
404
- if key not in self._resources and set_if_unavailable:
405
- await self.async_set_resource(key, default)
406
-
407
- return self._resources.get(key, default)
408
-
409
- async def async_set_resource(self, key: str, value: Any) -> None:
410
- async with self._lock:
411
- self._resources[key] = value
412
-
413
- async def async_pop_resource(self, key: str, default: Any = None) -> Any:
414
- async with self._lock:
415
- return self._resources.pop(key, default)