datashare-python 0.9.8__tar.gz → 0.9.9__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 (28) hide show
  1. {datashare_python-0.9.8 → datashare_python-0.9.9}/PKG-INFO +2 -2
  2. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/cli/task.py +2 -2
  3. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/constants.py +1 -0
  4. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/logging_.py +1 -1
  5. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/objects.py +125 -6
  6. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/utils.py +110 -20
  7. datashare_python-0.9.9/datashare_python/worker-template.tar.gz +0 -0
  8. {datashare_python-0.9.8 → datashare_python-0.9.9}/pyproject.toml +2 -2
  9. datashare_python-0.9.8/datashare_python/worker-template.tar.gz +0 -0
  10. {datashare_python-0.9.8 → datashare_python-0.9.9}/.gitignore +0 -0
  11. {datashare_python-0.9.8 → datashare_python-0.9.9}/README.md +0 -0
  12. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/.gitignore +0 -0
  13. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/__init__.py +0 -0
  14. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/__main__.py +0 -0
  15. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/cli/__init__.py +0 -0
  16. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/cli/project.py +0 -0
  17. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/cli/utils.py +0 -0
  18. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/cli/worker.py +0 -0
  19. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/config.py +0 -0
  20. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/conftest.py +0 -0
  21. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/dependencies.py +0 -0
  22. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/discovery.py +0 -0
  23. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/exceptions.py +0 -0
  24. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/interceptors.py +0 -0
  25. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/task_client.py +0 -0
  26. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/template.py +0 -0
  27. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/types_.py +0 -0
  28. {datashare_python-0.9.8 → datashare_python-0.9.9}/datashare_python/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datashare-python
3
- Version: 0.9.8
3
+ Version: 0.9.9
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/
@@ -11,7 +11,7 @@ Requires-Python: <4,>=3.11
11
11
  Requires-Dist: aiohttp~=3.11
12
12
  Requires-Dist: alive-progress~=3.2
13
13
  Requires-Dist: hatchling~=1.27
14
- Requires-Dist: icij-common[elasticsearch]~=0.8.2
14
+ Requires-Dist: icij-common[elasticsearch]~=0.8.3
15
15
  Requires-Dist: langcodes~=3.5
16
16
  Requires-Dist: lru-dict~=1.4
17
17
  Requires-Dist: pydantic-extra-types[pycountry]>=2.11.1
@@ -28,7 +28,7 @@ _START_HELP = "creates a new task and start it"
28
28
  _TASK_ID_HELP = "task ID"
29
29
  _WATCH_HELP = "watch a task until it's complete"
30
30
 
31
- TaskArgs = str
31
+ StrTaskArgs = str
32
32
 
33
33
  task_app = AsyncTyper(name="task")
34
34
 
@@ -36,7 +36,7 @@ task_app = AsyncTyper(name="task")
36
36
  @task_app.async_command(help=_START_HELP)
37
37
  async def start(
38
38
  name: Annotated[str, typer.Argument(help=_NAME_HELP)],
39
- args: Annotated[TaskArgs, typer.Argument(help=_ARGS_HELP)] = None,
39
+ args: Annotated[StrTaskArgs, typer.Argument(help=_ARGS_HELP)] = None,
40
40
  group: Annotated[
41
41
  str | None,
42
42
  typer.Option("--group", "-g", help=_GROUP_HELP),
@@ -11,6 +11,7 @@ DEFAULT_DS_ADDRESS = "http://localhost:8080"
11
11
  DEFAULT_NAMESPACE = "datashare-default"
12
12
 
13
13
  METADATA_JSON = "metadata.json"
14
+ MANIFEST_JSON = "manifest.json"
14
15
 
15
16
  TIKA_METADATA_RESOURCENAME = "tika_metadata_resourcename"
16
17
 
@@ -132,7 +132,7 @@ def _encode_value(value: Any) -> str:
132
132
  return "true" if value else "false"
133
133
  if isinstance(value, numbers.Number):
134
134
  return str(value)
135
- return json.dumps(value).decode()
135
+ return json.dumps(value)
136
136
 
137
137
 
138
138
  def _json_formatter(datefmt: str) -> BaseJsonFormatter:
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import os
3
+ from abc import ABC
3
4
  from asyncio import Lock
4
5
  from collections.abc import Awaitable, Callable
5
6
  from dataclasses import InitVar, dataclass, field
@@ -7,12 +8,15 @@ from datetime import UTC, datetime
7
8
  from enum import StrEnum, unique
8
9
  from io import BytesIO
9
10
  from pathlib import Path
10
- from typing import Annotated, Any, ClassVar, Literal, Self, TypeVar, cast
11
+ from typing import Annotated, Any, ClassVar, Generic, Literal, Self, TypeVar, cast
11
12
 
12
13
  import langcodes
14
+ from icij_common.registrable import Registrable
13
15
  from lru import LRU
14
16
  from pydantic_core import PydanticCustomError, ValidationError, core_schema
15
- from pydantic_core.core_schema import PlainValidatorFunctionSchema
17
+ from pydantic_core.core_schema import (
18
+ PlainValidatorFunctionSchema,
19
+ )
16
20
  from pydantic_extra_types.language_code import LanguageName
17
21
  from temporalio import workflow
18
22
 
@@ -34,12 +38,17 @@ with workflow.unsafe.imports_passed_through():
34
38
  from icij_common.pydantic_utils import (
35
39
  icij_config,
36
40
  lowercamel_case_config,
41
+ make_enum_discriminator,
37
42
  merge_configs,
38
43
  no_enum_values_config,
44
+ tagged_union,
39
45
  )
40
46
  from pydantic import (
41
47
  AfterValidator,
48
+ AliasChoices,
42
49
  BeforeValidator,
50
+ ConfigDict,
51
+ Discriminator,
43
52
  Field,
44
53
  GetCoreSchemaHandler,
45
54
  TypeAdapter,
@@ -256,13 +265,123 @@ def _is_absolute_path(v: bytes | BytesIO | Path) -> Any:
256
265
  return v
257
266
 
258
267
 
259
- @dataclass(frozen=True)
260
- class DocArtifact:
268
+ class ArtifactType(StrEnum):
269
+ STRUCTURE = "structure"
270
+ ASR_TRANSCRIPTION = "transcription"
271
+
272
+
273
+ class ManifestEntryStatus(StrEnum):
274
+ COMPLETE = "complete"
275
+
276
+
277
+ class TaskArgs(DatashareModel, ABC):
278
+ def as_manifest_task_input(self) -> dict[str, Any]:
279
+ # This is a base implementation, if the input is too large to be dumped,
280
+ # override this and pop large keys
281
+ as_manifest = self.model_dump(by_alias=True)
282
+ return as_manifest
283
+
284
+
285
+ A = TypeVar("A", bound=TaskArgs)
286
+
287
+
288
+ class ManifestEntry(DatashareModel, Generic[A], ABC):
289
+ status: ManifestEntryStatus
290
+ label: str | None = None
291
+ input: Annotated[
292
+ dict[str, Any] | None,
293
+ Field(
294
+ validation_alias=AliasChoices("taskInput", "input"),
295
+ serialization_alias="taskInput",
296
+ ),
297
+ ]
298
+
299
+ @classmethod
300
+ def complete(cls, args: A, label: str | None = None, **kwargs) -> Self:
301
+ return cls(
302
+ input=args.as_manifest_task_input(),
303
+ label=label,
304
+ status=ManifestEntryStatus.COMPLETE,
305
+ **kwargs,
306
+ )
307
+
308
+
309
+ class PaginationType(StrEnum):
310
+ FILESYSTEM = "filesystem"
311
+ BYTE_RANGES = "byteRanges"
312
+
313
+
314
+ class BasePagination(DatashareModel, Registrable, ABC):
315
+ registry_key: ClassVar[str] = Field(frozen=True, default="type")
316
+
317
+ total: int
318
+ type: ClassVar[PaginationType] = Field(frozen=True)
319
+
320
+
321
+ def _validate_pages_range(v: Any) -> None:
322
+ if not isinstance(v, list):
323
+ msg = f"expected a list, got {type(v)}"
324
+ raise TypeError(msg)
325
+ previous_end = None
326
+ for page_i, (start, end) in enumerate(v):
327
+ if not start <= end:
328
+ msg = "end of page must be >= start"
329
+ raise ValueError(msg)
330
+ if previous_end is not None and previous_end != start:
331
+ msg = (
332
+ f"start of page {page_i} doesn't match end of previous "
333
+ f"page {previous_end}"
334
+ )
335
+ raise ValueError(msg)
336
+ return v
337
+
338
+
339
+ PagesRange = Annotated[list[tuple[int, int]], AfterValidator(_validate_pages_range)]
340
+
341
+
342
+ @BasePagination.register(PaginationType.FILESYSTEM)
343
+ class FilesystemPagination(BasePagination):
344
+ type: ClassVar[PaginationType] = Field(
345
+ default=PaginationType.FILESYSTEM, frozen=True
346
+ )
347
+
348
+
349
+ @BasePagination.register(PaginationType.BYTE_RANGES)
350
+ class ByteRangesPagination(BasePagination):
351
+ type: ClassVar[PaginationType] = Field(
352
+ default=PaginationType.BYTE_RANGES, frozen=True
353
+ )
354
+ byte_ranges: PagesRange
355
+
356
+ @model_validator(mode="after")
357
+ def byte_ranges_length_should_match_total(self) -> Self:
358
+ if len(self.byte_ranges) != self.total:
359
+ msg = (
360
+ f"byte_ranges must match total. Found {len(self.byte_ranges)} for"
361
+ f" byte_ranges and {self.total} for total."
362
+ )
363
+ raise ValueError(msg)
364
+ return self
365
+
366
+
367
+ pagination_discriminator = make_enum_discriminator("type", PaginationType)
368
+ Pagination = Annotated[
369
+ tagged_union(BasePagination.__subclasses__(), lambda x: x.type.default),
370
+ Discriminator(pagination_discriminator),
371
+ ]
372
+
373
+
374
+ class DocArtifact(BaseModel, ABC):
375
+ # This object is not used for serde, just as a container, it's OK to allow
376
+ # arbitrary types (to allow storing BytesIO)
377
+ model_config = ConfigDict(arbitrary_types_allowed=True)
378
+
261
379
  project: str
262
380
  doc_id: str
263
381
  artifact: Annotated[bytes | BytesIO | Path, AfterValidator(_is_absolute_path)]
264
- filename: str
265
- metadata_key: str
382
+ filename: ClassVar[str] # Override this
383
+ type: ClassVar[ArtifactType] # Override this
384
+ manifest_entry: ManifestEntry
266
385
 
267
386
 
268
387
  @unique
@@ -1,10 +1,13 @@
1
1
  import asyncio
2
2
  import contextlib
3
+ import fcntl
3
4
  import inspect
4
5
  import json
5
6
  import os
6
7
  import shutil
7
- from collections.abc import Callable, Coroutine, Iterable, Sequence
8
+ import threading
9
+ import time
10
+ from collections.abc import Callable, Coroutine, Generator, Iterable, Sequence
8
11
  from copy import deepcopy
9
12
  from dataclasses import dataclass
10
13
  from datetime import timedelta
@@ -36,7 +39,7 @@ from datashare_python.types_ import (
36
39
  SyncProgressRateHandler,
37
40
  )
38
41
 
39
- from .constants import METADATA_JSON
42
+ from .constants import MANIFEST_JSON, METADATA_JSON
40
43
  from .objects import DocArtifact, DocumentLocation, FilesystemDocument
41
44
  from .types_ import RawAsyncProgressHandler
42
45
 
@@ -57,6 +60,11 @@ _NEVER_RETRIABLES = {
57
60
  }
58
61
 
59
62
 
63
+ _ARTIFACT_LOCK = threading.Lock()
64
+ # For test
65
+ _LOCKED = threading.Event()
66
+
67
+
60
68
  @dataclass(frozen=True)
61
69
  class Progress:
62
70
  max_progress: float
@@ -338,34 +346,116 @@ def _metadata_path(doc_id: str, *, project: str) -> Path:
338
346
  return metadata_path
339
347
 
340
348
 
341
- def _read_artifact_metadata(root: Path, artifact: DocArtifact) -> dict:
342
- m_path = root / _metadata_path(artifact.doc_id, project=artifact.project)
349
+ def _manifest_path(doc_id: str, *, project: str) -> Path:
350
+ manifest_path = artifacts_dir(doc_id, project=project) / MANIFEST_JSON
351
+ return manifest_path
352
+
353
+
354
+ def _read_artifact_manifest(root: Path, artifact: DocArtifact) -> dict:
355
+ m_path = root / _manifest_path(artifact.doc_id, project=artifact.project)
356
+ if not m_path.exists():
357
+ m_path = root / _metadata_path(artifact.doc_id, project=artifact.project)
358
+ if not m_path.exists():
359
+ msg = f"couldn't find manifest nor metadata for {artifact.doc_id}"
360
+ raise FileNotFoundError(msg)
343
361
  return json.loads(m_path.read_text())
344
362
 
345
363
 
346
- def write_artifact(root: Path, artifact: DocArtifact) -> Path:
364
+ def write_artifact(
365
+ root: Path, artifact: DocArtifact, lock_timeout_ms: int = 30_000
366
+ ) -> Path:
367
+ # TODO: WARNING many writers could write at the time, to avoid inconsistent
368
+ # states we should handle this somehow
347
369
  artif_dir = root / artifacts_dir(artifact.doc_id, project=artifact.project)
348
370
  artif_dir.mkdir(exist_ok=True, parents=True)
349
- # TODO: if transcriptions are too large we could also serialize them
350
- # as jsonl
351
- artifact_path: Path = artif_dir / artifact.filename
352
- match artifact.artifact:
371
+ artifact_path = artif_dir / artifact.filename
372
+ # We're using POSIX locks which are not exclusive to filedescriptors
373
+ # but to processes, we hence have to ensure several threads from the same
374
+ # Python process acquire the lock at the same time. We're using a python mutex lock
375
+ with artifact_lock(artif_dir, lock_timeout_ms):
376
+ # Read the metadata first (things could go wrong here in case someone is reading
377
+ # at the same time). We read in a backward compatible wat and write to that same
378
+ # location. We don't take responsibility for migrating the data, the DS back
379
+ # will do it
380
+ manifest_path, manifest = _read_manifest_backward_compatible(root, artifact)
381
+ is_legacy = manifest_path.name == "metadata.json"
382
+ # Pop the status key from the manifest before writing
383
+ manifest_entry = manifest.get(artifact.type)
384
+ if manifest_entry is not None and not is_legacy:
385
+ manifest[artifact.type].pop("status", None)
386
+ manifest_path.write_text(json.dumps(manifest))
387
+ # Write the artifact
388
+ _write_artifact_bytes(artifact_path, artifact.artifact)
389
+ # Update the manifest entry with details and new states
390
+ if is_legacy:
391
+ manifest_entry = str(artifact_path.relative_to(artif_dir))
392
+ else:
393
+ manifest_entry = artifact.manifest_entry.model_dump(
394
+ mode="json", by_alias=True
395
+ )
396
+ manifest[artifact.type] = manifest_entry
397
+ manifest_path.write_text(json.dumps(manifest))
398
+ return artifact_path.relative_to(artif_dir)
399
+
400
+
401
+ @contextlib.contextmanager
402
+ def _set_event() -> Generator[None, None, None]:
403
+ try:
404
+ _LOCKED.set()
405
+ yield
406
+ finally:
407
+ _LOCKED.clear()
408
+
409
+
410
+ @contextlib.contextmanager
411
+ def artifact_lock(
412
+ artifacts_dir: Path, lock_timeout_ms: int
413
+ ) -> Generator[None, None, None]:
414
+ lock_path = artifacts_dir / f"{MANIFEST_JSON}.lock"
415
+ fd = os.open(lock_path, os.O_CREAT | os.O_WRONLY)
416
+ with _ARTIFACT_LOCK, _set_event():
417
+ start = time.time()
418
+ timeout_s = lock_timeout_ms / 1000
419
+ while time.time() - start < timeout_s:
420
+ try:
421
+ fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
422
+ except OSError:
423
+ # TODO: we shouldn't block here, use asyncio instead
424
+ time.sleep(0.02)
425
+ continue
426
+ yield
427
+ return
428
+ msg = (
429
+ f"failed to acquire lock on {lock_path} in less than {lock_timeout_ms} ms."
430
+ )
431
+ raise TimeoutError(msg)
432
+
433
+
434
+ def _read_manifest_backward_compatible(
435
+ root: Path, artifact: DocArtifact
436
+ ) -> tuple[Path, dict[str, Any]]:
437
+ manifest_path = root / _manifest_path(artifact.doc_id, project=artifact.project)
438
+ if manifest_path.exists():
439
+ return manifest_path, _read_artifact_manifest(root, artifact)
440
+ meta_path = root / _metadata_path(artifact.doc_id, project=artifact.project)
441
+ if meta_path.exists():
442
+ return meta_path, _read_artifact_manifest(root, artifact)
443
+ return manifest_path, dict()
444
+
445
+
446
+ def _write_artifact_bytes(path: Path, artifact: bytes | BytesIO | Path) -> None:
447
+ match artifact:
353
448
  case bytes():
354
- artifact_path.write_bytes(artifact.artifact)
449
+ path.write_bytes(artifact)
355
450
  case BytesIO():
356
- with artifact_path.open("wb") as f:
357
- f.write(artifact.artifact.read())
451
+ with path.open("wb") as f:
452
+ f.write(artifact.read())
358
453
  case Path():
359
- artifact_path.unlink(missing_ok=True)
360
- shutil.move(artifact.artifact, artifact_path)
454
+ path.unlink(missing_ok=True)
455
+ shutil.move(artifact, path)
361
456
  case _:
362
- msg = f"unsupported artifact type: {artifact.artifact.__class__.__name__}"
457
+ msg = f"unsupported artifact type: {artifact.__class__.__name__}"
363
458
  raise ValueError(msg)
364
- meta_path = root / _metadata_path(artifact.doc_id, project=artifact.project)
365
- meta = _read_artifact_metadata(root, artifact) if meta_path.exists() else dict()
366
- meta[artifact.metadata_key] = artifact.filename
367
- meta_path.write_text(json.dumps(meta))
368
- return artifact_path.relative_to(artif_dir)
369
459
 
370
460
 
371
461
  def debuggable_name(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datashare-python"
3
- version = "0.9.8"
3
+ version = "0.9.9"
4
4
  description = "Manage Python tasks and local resources in Datashare"
5
5
  authors = [
6
6
  { name = "Clément Doumouro", email = "cdoumouro@icij.org" },
@@ -13,7 +13,7 @@ dependencies = [
13
13
  "alive-progress~=3.2",
14
14
  "aiohttp~=3.11",
15
15
  "hatchling~=1.27",
16
- "icij-common[elasticsearch]~=0.8.2",
16
+ "icij-common[elasticsearch]~=0.8.3",
17
17
  "langcodes~=3.5",
18
18
  "python-json-logger~=4.0",
19
19
  "pyyaml~=6.0",