datashare-python 0.10.0.dev0__tar.gz → 0.10.1__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.10.0.dev0 → datashare_python-0.10.1}/PKG-INFO +1 -1
  2. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/config.py +1 -0
  3. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/conftest.py +3 -2
  4. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/interceptors.py +85 -33
  5. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/objects.py +2 -2
  6. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/types_.py +13 -7
  7. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/utils.py +16 -16
  8. datashare_python-0.10.1/datashare_python/worker-template.tar.gz +0 -0
  9. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/worker.py +3 -1
  10. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/pyproject.toml +1 -1
  11. datashare_python-0.10.0.dev0/datashare_python/worker-template.tar.gz +0 -0
  12. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/.gitignore +0 -0
  13. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/README.md +0 -0
  14. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/.gitignore +0 -0
  15. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/__init__.py +0 -0
  16. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/__main__.py +0 -0
  17. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/cli/__init__.py +0 -0
  18. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/cli/project.py +0 -0
  19. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/cli/task.py +0 -0
  20. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/cli/utils.py +0 -0
  21. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/cli/worker.py +0 -0
  22. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/constants.py +0 -0
  23. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/dependencies.py +0 -0
  24. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/discovery.py +0 -0
  25. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/exceptions.py +0 -0
  26. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/logging_.py +0 -0
  27. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/mimetypes_.py +0 -0
  28. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/task_client.py +0 -0
  29. {datashare_python-0.10.0.dev0 → datashare_python-0.10.1}/datashare_python/template.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datashare-python
3
- Version: 0.10.0.dev0
3
+ Version: 0.10.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/
@@ -122,6 +122,7 @@ class WorkerConfig(ICIJSettings, BaseModel):
122
122
  temporal: TemporalClientConfig = TemporalClientConfig()
123
123
 
124
124
  max_concurrent_activities: int = 5
125
+ min_progress_interval_s: float = 30.0
125
126
 
126
127
  paths: WorkerPaths | None = None
127
128
 
@@ -10,7 +10,6 @@ from multiprocessing import Event
10
10
  from pathlib import Path
11
11
 
12
12
  import aiohttp
13
- import nest_asyncio
14
13
  import pytest
15
14
  from elasticsearch._async.helpers import async_streaming_bulk
16
15
  from icij_common.es import DOC_ROOT_ID, ES_DOCUMENT_TYPE, ID, ESClient
@@ -171,7 +170,7 @@ async def test_temporal_client(
171
170
 
172
171
 
173
172
  @pytest.fixture
174
- async def indexed_docs(
173
+ async def indexed_docs( # noqa: PLR0917
175
174
  doc_0: Document,
176
175
  doc_1: Document,
177
176
  doc_2: Document,
@@ -299,6 +298,8 @@ async def all_done(task_client: DatashareTaskClient, not_done: list[str]) -> boo
299
298
 
300
299
  @pytest.fixture # noqa: F405
301
300
  def typer_asyncio_patch() -> None:
301
+ import nest_asyncio # noqa: PLC0415
302
+
302
303
  nest_asyncio.apply()
303
304
 
304
305
 
@@ -1,12 +1,12 @@
1
1
  import asyncio
2
- import contextlib
3
2
  import dataclasses
3
+ import datetime
4
4
  import secrets
5
5
  from collections.abc import Callable, Generator, Mapping
6
6
  from contextlib import contextmanager
7
7
  from contextvars import ContextVar
8
8
  from copy import deepcopy
9
- from functools import partial, wraps
9
+ from functools import wraps
10
10
  from inspect import signature
11
11
  from types import UnionType
12
12
  from typing import (
@@ -29,6 +29,7 @@ from temporalio.client import WorkflowHandle
29
29
  from temporalio.converter import DataConverter
30
30
  from temporalio.worker import (
31
31
  ActivityInboundInterceptor,
32
+ ActivityOutboundInterceptor,
32
33
  ContinueAsNewInput,
33
34
  ExecuteActivityInput,
34
35
  ExecuteWorkflowInput,
@@ -219,7 +220,7 @@ def _trace_context(headers: Mapping[str, Payload]) -> Generator[None, None, None
219
220
  InputWithHeaders = TypeVar("InputWithHeaders")
220
221
 
221
222
 
222
- def _with_trace_context_header(
223
+ def _with_trace_context_header[InputWithHeaders](
223
224
  input_with_headers: InputWithHeaders,
224
225
  ) -> InputWithHeaders:
225
226
  ctx = get_trace_context()
@@ -234,11 +235,14 @@ def _with_trace_context_header(
234
235
 
235
236
 
236
237
  class ProgressInterceptor(Interceptor):
238
+ def __init__(self, min_progress_interval_s: float = 30.0):
239
+ self._min_progress_interval_s: float = min_progress_interval_s
240
+
237
241
  def intercept_activity(
238
242
  self,
239
243
  next: ActivityInboundInterceptor, # noqa: A002
240
244
  ) -> ActivityInboundInterceptor:
241
- return _ProgressInboundInterceptor(next)
245
+ return _ProgressInboundInterceptor(next, self._min_progress_interval_s)
242
246
 
243
247
 
244
248
  def _parse_progress_weight(act_fn: Callable) -> float:
@@ -254,18 +258,41 @@ def _parse_progress_weight(act_fn: Callable) -> float:
254
258
  return 1.0
255
259
 
256
260
 
257
- async def progress_handler(
258
- progress: float,
259
- handle: WorkflowHandle,
260
- *,
261
- activity_id: str,
262
- run_id: str,
263
- weight: float = 1.0,
264
- ) -> None:
265
- signal = ProgressSignal(
266
- activity_id=activity_id, run_id=run_id, progress=progress, weight=weight
267
- )
268
- await handle.signal("update_progress", signal)
261
+ class TemporalProgressHandler:
262
+ def __init__(
263
+ self,
264
+ handle: WorkflowHandle,
265
+ activity_id: str,
266
+ *,
267
+ run_id: str,
268
+ min_progress_interval_s: float = 30.0,
269
+ weight: float = 1.0,
270
+ ) -> None:
271
+ self._handle = handle
272
+ self._activity_id = activity_id
273
+ self._run_id = run_id
274
+ self._weight = weight
275
+ self._min_progress_interval_s = min_progress_interval_s
276
+ self._last: datetime.datetime | None = None
277
+
278
+ async def progress(self, progress: float, *, force: bool = False) -> None:
279
+ # TODO: we could lock here to avoid race conditions, it's not critical though
280
+ now = datetime.datetime.now(datetime.UTC)
281
+ report_progress = (
282
+ force
283
+ or self._last is None
284
+ or (now - self._last).total_seconds() >= self._min_progress_interval_s
285
+ )
286
+ if not report_progress:
287
+ return
288
+ self._last = now
289
+ signal = ProgressSignal(
290
+ activity_id=self._activity_id,
291
+ run_id=self._run_id,
292
+ progress=progress,
293
+ weight=self._weight,
294
+ )
295
+ await self._handle.signal("update_progress", signal)
269
296
 
270
297
 
271
298
  def supports_progress(task_fn: Callable) -> bool:
@@ -275,7 +302,9 @@ def supports_progress(task_fn: Callable) -> bool:
275
302
  )
276
303
 
277
304
 
278
- def _get_progress_handler(act_fn: Callable) -> ProgressRateHandler:
305
+ def _get_progress_handler(
306
+ act_fn: Callable, min_progress_interval_s: float
307
+ ) -> ProgressRateHandler:
279
308
  act = getattr(act_fn, "__self__", None)
280
309
  # Weirdly isinstance doesn't work here
281
310
  if act is None or not isinstance(act, ActivityWithProgress):
@@ -291,14 +320,14 @@ def _get_progress_handler(act_fn: Callable) -> ProgressRateHandler:
291
320
  activity_id = activity.info().activity_id
292
321
  client = act._temporal_client
293
322
  workflow_handle = client.get_workflow_handle(workflow_id, run_id=run_id)
294
- handler = partial(
295
- progress_handler,
296
- handle=workflow_handle,
323
+ handler = TemporalProgressHandler(
324
+ workflow_handle,
325
+ activity_id,
297
326
  run_id=run_id,
298
- activity_id=activity_id,
299
327
  weight=weight,
328
+ min_progress_interval_s=min_progress_interval_s,
300
329
  )
301
- return handler
330
+ return handler.progress
302
331
 
303
332
 
304
333
  def _is_progress(t: type) -> bool:
@@ -320,13 +349,23 @@ def _without_progress(arg_types: list[type] | None) -> list[type] | None:
320
349
 
321
350
 
322
351
  class _ProgressInboundInterceptor(ActivityInboundInterceptor):
352
+ def __init__(
353
+ self,
354
+ next: ActivityInboundInterceptor, # noqa: A002
355
+ min_progress_interval_s: float,
356
+ ) -> None:
357
+ super().__init__(next)
358
+ self._min_progress_interval_s = min_progress_interval_s
359
+
323
360
  async def execute_activity(self, input: ExecuteActivityInput) -> Any: # noqa: A002
324
361
  if not supports_progress(input.fn):
325
362
  return await super().execute_activity(input)
326
363
  # The progress args breaks trigger a bypass of the dataloader:
327
364
  # https://github.com/temporalio/sdk-python/blob/631ebaf0e20fb214b16589b45627b358048a5d77/temporalio/worker/_activity.py#L600
328
365
  # we have to force it here again
329
- progress_handler = _get_progress_handler(input.fn)
366
+ progress_handler = _get_progress_handler(
367
+ input.fn, self._min_progress_interval_s
368
+ )
330
369
  new_args = []
331
370
  act_definition = _Definition.must_from_callable(input.fn)
332
371
  if input.args:
@@ -343,9 +382,9 @@ class _ProgressInboundInterceptor(ActivityInboundInterceptor):
343
382
  )
344
383
  new_args.append(injected_progress)
345
384
  new_input = dataclasses.replace(input, args=new_args)
346
- await progress_handler(0.0)
385
+ await progress_handler(0.0, force=True)
347
386
  res = await super().execute_activity(new_input)
348
- await progress_handler(1.0)
387
+ await progress_handler(1.0, force=True)
349
388
  return res
350
389
 
351
390
 
@@ -360,13 +399,13 @@ class HeartbeatInterceptor(Interceptor):
360
399
  return _HeartbeatInboundInterceptor(next, self._n_missed_before_timeout)
361
400
 
362
401
 
363
- async def _heartbeat_every(period: float, *details: Any) -> None:
364
- with contextlib.suppress(RuntimeError, asyncio.TimeoutError):
365
- activity.heartbeat(*details)
402
+ async def _heartbeat_every(
403
+ heartbeat_fn: Callable[[Any], None], period: float, *details: Any
404
+ ) -> None:
405
+ heartbeat_fn(*details)
366
406
  while True:
367
407
  await asyncio.sleep(period)
368
- with contextlib.suppress(RuntimeError, asyncio.TimeoutError):
369
- activity.heartbeat(*details)
408
+ heartbeat_fn(*details)
370
409
 
371
410
 
372
411
  class _HeartbeatInboundInterceptor(ActivityInboundInterceptor):
@@ -377,13 +416,24 @@ class _HeartbeatInboundInterceptor(ActivityInboundInterceptor):
377
416
  ) -> None:
378
417
  super().__init__(next)
379
418
  self._n_missed_before_timeout = n_missed_before_timeout
419
+ self._outbound: ActivityOutboundInterceptor | None = None
420
+
421
+ def init(self, outbound: ActivityOutboundInterceptor) -> None:
422
+ super().init(outbound)
423
+ self._outbound = outbound
380
424
 
381
425
  async def execute_activity(self, input: ExecuteActivityInput) -> Any: # noqa: A002
426
+ # We need to capture the output interceptor to access the outbound hearbeat
427
+ # function. This inbound interceptor runs on the temporal event loop side.
428
+ # We want the heartbeat to run on the worker thread, on on the temporal event
429
+ # loop !
382
430
  heartbeat_timeout = activity.info().heartbeat_timeout
383
431
  heartbeat_task = None
384
432
  if heartbeat_timeout:
385
433
  period = heartbeat_timeout.total_seconds() / self._n_missed_before_timeout
386
- heartbeat_task = asyncio.create_task(_heartbeat_every(period))
434
+ heartbeat_task = asyncio.create_task(
435
+ _heartbeat_every(self._outbound.heartbeat, period)
436
+ )
387
437
  try:
388
438
  activity.heartbeat()
389
439
  return await super().execute_activity(input)
@@ -397,9 +447,11 @@ def _sync_progress(
397
447
  progress_handler: AsyncProgressRateHandler,
398
448
  ) -> SyncProgressRateHandler:
399
449
  @wraps(progress_handler)
400
- def p(progress: float, event_loop: asyncio.AbstractEventLoop) -> None:
450
+ def p(
451
+ progress: float, event_loop: asyncio.AbstractEventLoop, *, force: bool = False
452
+ ) -> None:
401
453
  asyncio.run_coroutine_threadsafe(
402
- progress_handler(progress), event_loop
454
+ progress_handler(progress, force=force), event_loop
403
455
  ).result()
404
456
 
405
457
  return p
@@ -7,7 +7,7 @@ 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, Generic, Literal, Self, TypeVar, cast
10
+ from typing import Annotated, Any, ClassVar, Literal, Self, TypeVar, cast
11
11
 
12
12
  import langcodes
13
13
  from icij_common.registrable import Registrable
@@ -323,7 +323,7 @@ class TaskArgs(DatashareModel, ABC):
323
323
  A = TypeVar("A", bound=TaskArgs)
324
324
 
325
325
 
326
- class ManifestEntry(DatashareModel, Generic[A], ABC):
326
+ class ManifestEntry[A](DatashareModel, ABC):
327
327
  status: ManifestEntryStatus
328
328
  label: str | None = None
329
329
  input: Annotated[
@@ -10,15 +10,17 @@ TemporalClient = Client
10
10
 
11
11
 
12
12
  class AsyncProgressRateHandler(Protocol):
13
- async def __call__(self, progress_rate: float) -> None:
14
- pass
13
+ async def __call__(self, progress_rate: float, *, force: bool = False) -> None: ...
15
14
 
16
15
 
17
16
  class SyncProgressRateHandler(Protocol):
18
17
  def __call__(
19
- self, progress_rate: float, event_loop: asyncio.AbstractEventLoop
20
- ) -> None:
21
- pass
18
+ self,
19
+ progress_rate: float,
20
+ event_loop: asyncio.AbstractEventLoop,
21
+ *,
22
+ force: bool = False,
23
+ ) -> None: ...
22
24
 
23
25
 
24
26
  ProgressRateHandler = SyncProgressRateHandler | AsyncProgressRateHandler
@@ -30,12 +32,16 @@ class Weight:
30
32
 
31
33
 
32
34
  class RawAsyncProgressHandler(Protocol):
33
- async def __call__(self, iteration: int) -> None: ...
35
+ async def __call__(self, iteration: int, *, force: bool = False) -> None: ...
34
36
 
35
37
 
36
38
  class RawSyncProgressHandler(Protocol):
37
39
  async def __call__(
38
- self, iteration: int, event_loop: asyncio.AbstractEventLoop
40
+ self,
41
+ iteration: int,
42
+ event_loop: asyncio.AbstractEventLoop,
43
+ *,
44
+ force: bool = False,
39
45
  ) -> None: ...
40
46
 
41
47
 
@@ -25,7 +25,7 @@ from functools import cache, wraps
25
25
  from hashlib import sha256
26
26
  from io import BytesIO
27
27
  from pathlib import Path
28
- from typing import Any, ParamSpec, Self, TypeVar
28
+ from typing import Any, Self, TypeVar
29
29
  from uuid import uuid4
30
30
 
31
31
  import temporalio
@@ -70,8 +70,6 @@ DependencyAsyncSetup = Callable[..., Coroutine[None, None, None]]
70
70
 
71
71
  PROGRESS_HANDLER_ARG = "progress"
72
72
 
73
- P = ParamSpec("P")
74
- T = TypeVar("T")
75
73
 
76
74
  _NEVER_RETRIABLES = {
77
75
  "ValidationError",
@@ -178,7 +176,7 @@ async def execute_activity(
178
176
  )
179
177
 
180
178
 
181
- def positional_args_only(activity_fn: Callable[P, T]) -> Callable[P, T]:
179
+ def positional_args_only[**P, T](activity_fn: Callable[P, T]) -> Callable[P, T]:
182
180
  sig = inspect.signature(activity_fn)
183
181
 
184
182
  # Keep track of kwargs-only
@@ -226,7 +224,7 @@ def _unpack_positional_args(
226
224
  return new_args, new_kwargs
227
225
 
228
226
 
229
- def with_retriables(
227
+ def with_retriables[**P, T](
230
228
  retriables: set[type[Exception]] = None,
231
229
  ) -> Callable[[Callable[P, T]], Callable[P, T]]:
232
230
  if retriables is None:
@@ -281,7 +279,7 @@ def with_retriables(
281
279
  return decorator
282
280
 
283
281
 
284
- def activity_defn(
282
+ def activity_defn[**P, T](
285
283
  name: str, retriables: set[type[Exception]] = None
286
284
  ) -> Callable[[Callable[P, T]], Callable[P, T]]:
287
285
 
@@ -319,8 +317,8 @@ def to_raw_async_progress(
319
317
  if not max_progress > 0:
320
318
  raise ValueError("max_progress must be > 0")
321
319
 
322
- async def raw(p: int) -> None:
323
- await progress(p / max_progress)
320
+ async def raw(p: int, *, force: bool = False) -> None:
321
+ await progress(p / max_progress, force=force)
324
322
 
325
323
  return raw
326
324
 
@@ -331,10 +329,10 @@ def to_incremental_async_progress(
331
329
 
332
330
  offset = 0
333
331
 
334
- async def incremental(p: int) -> None:
332
+ async def incremental(p: int, *, force: bool = False) -> None:
335
333
  nonlocal offset
336
334
  offset += p
337
- await progress(offset)
335
+ await progress(offset, force=force)
338
336
 
339
337
  return incremental
340
338
 
@@ -345,8 +343,10 @@ def to_raw_sync_progress(
345
343
  if not max_progress > 0:
346
344
  raise ValueError("max_progress must be > 0")
347
345
 
348
- def raw(iteration: int, event_loop: asyncio.AbstractEventLoop) -> None:
349
- progress(iteration / max_progress, event_loop)
346
+ def raw(
347
+ iteration: int, event_loop: asyncio.AbstractEventLoop, *, force: bool = False
348
+ ) -> None:
349
+ progress(iteration / max_progress, event_loop, force=force)
350
350
 
351
351
  return raw
352
352
 
@@ -359,8 +359,8 @@ def to_scaled_async_progress(
359
359
  if not start < end <= 1.0:
360
360
  raise ValueError("end must be ]start, 1.0]")
361
361
 
362
- async def _scaled(p: float) -> None:
363
- await progress(start + p * (end - start))
362
+ async def _scaled(p: float, *, force: bool = False) -> None:
363
+ await progress(start + p * (end - start), force=force)
364
364
 
365
365
  return _scaled
366
366
 
@@ -615,11 +615,11 @@ def _read_jsonl(path: Path) -> Iterable[dict]:
615
615
  M = TypeVar("M", bound=BaseModel)
616
616
 
617
617
 
618
- def read_jsonl_as(path: Path, cls: type[M]) -> Iterable[M]:
618
+ def read_jsonl_as[M](path: Path, cls: type[M]) -> Iterable[M]:
619
619
  return (cls.model_validate(d) for d in _read_jsonl(path))
620
620
 
621
621
 
622
- async def async_read_jsonl_as(
622
+ async def async_read_jsonl_as[M](
623
623
  path: Path, processed_file_cls: type[M]
624
624
  ) -> AsyncIterable[M]:
625
625
  async with async_open(path, "r") as f:
@@ -83,6 +83,7 @@ def datashare_worker(
83
83
  # at a time
84
84
  max_concurrent_activities: int = 1,
85
85
  max_activities_per_second: float = 20.0,
86
+ min_progress_interval_s: float = 30.0,
86
87
  sandboxed: bool = True,
87
88
  ) -> DatashareWorker:
88
89
  if workflows is None:
@@ -108,7 +109,7 @@ def datashare_worker(
108
109
  logger.warning(_SEPARATE_IO_AND_CPU_WORKERS)
109
110
  interceptors = [
110
111
  TraceContextInterceptor(),
111
- ProgressInterceptor(),
112
+ ProgressInterceptor(min_progress_interval_s=min_progress_interval_s),
112
113
  HeartbeatInterceptor(),
113
114
  ]
114
115
  wf_runner = SandboxedWorkflowRunner() if sandboxed else UnsandboxedWorkflowRunner()
@@ -216,6 +217,7 @@ async def worker_context(
216
217
  activities=acts,
217
218
  task_queue=task_queue,
218
219
  max_concurrent_activities=worker_config.max_concurrent_activities,
220
+ min_progress_interval_s=worker_config.min_progress_interval_s,
219
221
  sandboxed=sandboxed,
220
222
  )
221
223
  async with worker:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datashare-python"
3
- version = "0.10.0dev0"
3
+ version = "0.10.1"
4
4
  description = "Manage Python tasks and local resources in Datashare"
5
5
  authors = [
6
6
  { name = "Clément Doumouro", email = "cdoumouro@icij.org" },