datashare-python 0.10.0rc1__tar.gz → 0.10.4__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.0rc1 → datashare_python-0.10.4}/PKG-INFO +2 -2
  2. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/config.py +1 -0
  3. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/conftest.py +3 -2
  4. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/interceptors.py +108 -33
  5. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/objects.py +13 -3
  6. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/types_.py +13 -7
  7. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/utils.py +16 -16
  8. datashare_python-0.10.4/datashare_python/worker-template.tar.gz +0 -0
  9. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/worker.py +3 -1
  10. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/pyproject.toml +1 -1
  11. datashare_python-0.10.0rc1/datashare_python/worker-template.tar.gz +0 -0
  12. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/.gitignore +0 -0
  13. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/README.md +0 -0
  14. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/.gitignore +0 -0
  15. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/__init__.py +0 -0
  16. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/__main__.py +0 -0
  17. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/cli/__init__.py +0 -0
  18. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/cli/project.py +0 -0
  19. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/cli/task.py +0 -0
  20. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/cli/utils.py +0 -0
  21. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/cli/worker.py +0 -0
  22. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/constants.py +0 -0
  23. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/dependencies.py +0 -0
  24. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/discovery.py +0 -0
  25. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/exceptions.py +0 -0
  26. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/logging_.py +0 -0
  27. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/mimetypes_.py +0 -0
  28. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/task_client.py +0 -0
  29. {datashare_python-0.10.0rc1 → datashare_python-0.10.4}/datashare_python/template.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: datashare-python
3
- Version: 0.10.0rc1
3
+ Version: 0.10.4
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,13 @@
1
1
  import asyncio
2
- import contextlib
3
2
  import dataclasses
3
+ import datetime
4
+ import logging
4
5
  import secrets
5
6
  from collections.abc import Callable, Generator, Mapping
6
7
  from contextlib import contextmanager
7
8
  from contextvars import ContextVar
8
9
  from copy import deepcopy
9
- from functools import partial, wraps
10
+ from functools import wraps
10
11
  from inspect import signature
11
12
  from types import UnionType
12
13
  from typing import (
@@ -29,6 +30,7 @@ from temporalio.client import WorkflowHandle
29
30
  from temporalio.converter import DataConverter
30
31
  from temporalio.worker import (
31
32
  ActivityInboundInterceptor,
33
+ ActivityOutboundInterceptor,
32
34
  ContinueAsNewInput,
33
35
  ExecuteActivityInput,
34
36
  ExecuteWorkflowInput,
@@ -65,6 +67,8 @@ from .utils import (
65
67
  ProgressSignal,
66
68
  )
67
69
 
70
+ logger = logging.getLogger(__name__)
71
+
68
72
  _TRACEPARENT = "traceparent"
69
73
  _DEFAULT_PAYLOAD_CONVERTER = DataConverter.default.payload_converter
70
74
  _PROGRESS_TYPES = {
@@ -189,6 +193,8 @@ class _TraceContextWorkflowOutboundInterceptor(WorkflowOutboundInterceptor):
189
193
 
190
194
  class _TraceContextActivityInboundInterceptor(ActivityInboundInterceptor):
191
195
  async def execute_activity(self, input: ExecuteActivityInput) -> Any: # noqa: A002
196
+ info = activity.info()
197
+ logger.info("start executing activity: %s", info.activity_id)
192
198
  with _trace_context(input.headers):
193
199
  return await super().execute_activity(input)
194
200
 
@@ -219,7 +225,7 @@ def _trace_context(headers: Mapping[str, Payload]) -> Generator[None, None, None
219
225
  InputWithHeaders = TypeVar("InputWithHeaders")
220
226
 
221
227
 
222
- def _with_trace_context_header(
228
+ def _with_trace_context_header[InputWithHeaders](
223
229
  input_with_headers: InputWithHeaders,
224
230
  ) -> InputWithHeaders:
225
231
  ctx = get_trace_context()
@@ -234,11 +240,14 @@ def _with_trace_context_header(
234
240
 
235
241
 
236
242
  class ProgressInterceptor(Interceptor):
243
+ def __init__(self, min_progress_interval_s: float = 30.0):
244
+ self._min_progress_interval_s: float = min_progress_interval_s
245
+
237
246
  def intercept_activity(
238
247
  self,
239
248
  next: ActivityInboundInterceptor, # noqa: A002
240
249
  ) -> ActivityInboundInterceptor:
241
- return _ProgressInboundInterceptor(next)
250
+ return _ProgressInboundInterceptor(next, self._min_progress_interval_s)
242
251
 
243
252
 
244
253
  def _parse_progress_weight(act_fn: Callable) -> float:
@@ -254,18 +263,41 @@ def _parse_progress_weight(act_fn: Callable) -> float:
254
263
  return 1.0
255
264
 
256
265
 
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)
266
+ class TemporalProgressHandler:
267
+ def __init__(
268
+ self,
269
+ handle: WorkflowHandle,
270
+ activity_id: str,
271
+ *,
272
+ run_id: str,
273
+ min_progress_interval_s: float = 30.0,
274
+ weight: float = 1.0,
275
+ ) -> None:
276
+ self._handle = handle
277
+ self._activity_id = activity_id
278
+ self._run_id = run_id
279
+ self._weight = weight
280
+ self._min_progress_interval_s = min_progress_interval_s
281
+ self._last: datetime.datetime | None = None
282
+
283
+ async def progress(self, progress: float, *, force: bool = False) -> None:
284
+ # TODO: we could lock here to avoid race conditions, it's not critical though
285
+ now = datetime.datetime.now(datetime.UTC)
286
+ report_progress = (
287
+ force
288
+ or self._last is None
289
+ or (now - self._last).total_seconds() >= self._min_progress_interval_s
290
+ )
291
+ if not report_progress:
292
+ return
293
+ self._last = now
294
+ signal = ProgressSignal(
295
+ activity_id=self._activity_id,
296
+ run_id=self._run_id,
297
+ progress=progress,
298
+ weight=self._weight,
299
+ )
300
+ await self._handle.signal("update_progress", signal)
269
301
 
270
302
 
271
303
  def supports_progress(task_fn: Callable) -> bool:
@@ -275,7 +307,9 @@ def supports_progress(task_fn: Callable) -> bool:
275
307
  )
276
308
 
277
309
 
278
- def _get_progress_handler(act_fn: Callable) -> ProgressRateHandler:
310
+ def _get_progress_handler(
311
+ act_fn: Callable, min_progress_interval_s: float
312
+ ) -> ProgressRateHandler:
279
313
  act = getattr(act_fn, "__self__", None)
280
314
  # Weirdly isinstance doesn't work here
281
315
  if act is None or not isinstance(act, ActivityWithProgress):
@@ -291,14 +325,14 @@ def _get_progress_handler(act_fn: Callable) -> ProgressRateHandler:
291
325
  activity_id = activity.info().activity_id
292
326
  client = act._temporal_client
293
327
  workflow_handle = client.get_workflow_handle(workflow_id, run_id=run_id)
294
- handler = partial(
295
- progress_handler,
296
- handle=workflow_handle,
328
+ handler = TemporalProgressHandler(
329
+ workflow_handle,
330
+ activity_id,
297
331
  run_id=run_id,
298
- activity_id=activity_id,
299
332
  weight=weight,
333
+ min_progress_interval_s=min_progress_interval_s,
300
334
  )
301
- return handler
335
+ return handler.progress
302
336
 
303
337
 
304
338
  def _is_progress(t: type) -> bool:
@@ -320,13 +354,23 @@ def _without_progress(arg_types: list[type] | None) -> list[type] | None:
320
354
 
321
355
 
322
356
  class _ProgressInboundInterceptor(ActivityInboundInterceptor):
357
+ def __init__(
358
+ self,
359
+ next: ActivityInboundInterceptor, # noqa: A002
360
+ min_progress_interval_s: float,
361
+ ) -> None:
362
+ super().__init__(next)
363
+ self._min_progress_interval_s = min_progress_interval_s
364
+
323
365
  async def execute_activity(self, input: ExecuteActivityInput) -> Any: # noqa: A002
324
366
  if not supports_progress(input.fn):
325
367
  return await super().execute_activity(input)
326
368
  # The progress args breaks trigger a bypass of the dataloader:
327
369
  # https://github.com/temporalio/sdk-python/blob/631ebaf0e20fb214b16589b45627b358048a5d77/temporalio/worker/_activity.py#L600
328
370
  # we have to force it here again
329
- progress_handler = _get_progress_handler(input.fn)
371
+ progress_handler = _get_progress_handler(
372
+ input.fn, self._min_progress_interval_s
373
+ )
330
374
  new_args = []
331
375
  act_definition = _Definition.must_from_callable(input.fn)
332
376
  if input.args:
@@ -343,9 +387,9 @@ class _ProgressInboundInterceptor(ActivityInboundInterceptor):
343
387
  )
344
388
  new_args.append(injected_progress)
345
389
  new_input = dataclasses.replace(input, args=new_args)
346
- await progress_handler(0.0)
390
+ await progress_handler(0.0, force=True)
347
391
  res = await super().execute_activity(new_input)
348
- await progress_handler(1.0)
392
+ await progress_handler(1.0, force=True)
349
393
  return res
350
394
 
351
395
 
@@ -360,13 +404,13 @@ class HeartbeatInterceptor(Interceptor):
360
404
  return _HeartbeatInboundInterceptor(next, self._n_missed_before_timeout)
361
405
 
362
406
 
363
- async def _heartbeat_every(period: float, *details: Any) -> None:
364
- with contextlib.suppress(RuntimeError, asyncio.TimeoutError):
365
- activity.heartbeat(*details)
407
+ async def _heartbeat_every(
408
+ heartbeat_fn: Callable[[Any], None], period: float, *details: Any
409
+ ) -> None:
410
+ heartbeat_fn(*details)
366
411
  while True:
367
412
  await asyncio.sleep(period)
368
- with contextlib.suppress(RuntimeError, asyncio.TimeoutError):
369
- activity.heartbeat(*details)
413
+ heartbeat_fn(*details)
370
414
 
371
415
 
372
416
  class _HeartbeatInboundInterceptor(ActivityInboundInterceptor):
@@ -377,13 +421,25 @@ class _HeartbeatInboundInterceptor(ActivityInboundInterceptor):
377
421
  ) -> None:
378
422
  super().__init__(next)
379
423
  self._n_missed_before_timeout = n_missed_before_timeout
424
+ self._outbound: ActivityOutboundInterceptor | None = None
425
+
426
+ def init(self, outbound: ActivityOutboundInterceptor) -> None:
427
+ super().init(outbound)
428
+ self._outbound = outbound
380
429
 
381
430
  async def execute_activity(self, input: ExecuteActivityInput) -> Any: # noqa: A002
431
+ # We need to capture the output interceptor to access the outbound hearbeat
432
+ # function. This inbound interceptor runs on the temporal event loop side.
433
+ # We want the heartbeat to run on the worker thread, on on the temporal event
434
+ # loop !
382
435
  heartbeat_timeout = activity.info().heartbeat_timeout
383
436
  heartbeat_task = None
384
437
  if heartbeat_timeout:
385
438
  period = heartbeat_timeout.total_seconds() / self._n_missed_before_timeout
386
- heartbeat_task = asyncio.create_task(_heartbeat_every(period))
439
+ # We don't want a failing hearbeat to fail the worker task so we just
440
+ # ignore any exception
441
+ heartbeat_fn = _fail_safe(self._outbound.heartbeat)
442
+ heartbeat_task = asyncio.create_task(_heartbeat_every(heartbeat_fn, period))
387
443
  try:
388
444
  activity.heartbeat()
389
445
  return await super().execute_activity(input)
@@ -393,13 +449,32 @@ class _HeartbeatInboundInterceptor(ActivityInboundInterceptor):
393
449
  await asyncio.wait([heartbeat_task])
394
450
 
395
451
 
452
+ def _fail_safe(
453
+ fn: Callable[[Any], None], excs: tuple[type[Exception]] | None = None
454
+ ) -> Callable[[Any], None]:
455
+ if excs is None:
456
+ excs = (Exception,)
457
+
458
+ @wraps(fn)
459
+ def wrapper(*args, **kwargs) -> None:
460
+ try:
461
+ fn(*args, **kwargs)
462
+ except excs as exc:
463
+ msg = f"failed to heartbeat due to {exc}"
464
+ logger.exception(msg)
465
+
466
+ return wrapper
467
+
468
+
396
469
  def _sync_progress(
397
470
  progress_handler: AsyncProgressRateHandler,
398
471
  ) -> SyncProgressRateHandler:
399
472
  @wraps(progress_handler)
400
- def p(progress: float, event_loop: asyncio.AbstractEventLoop) -> None:
473
+ def p(
474
+ progress: float, event_loop: asyncio.AbstractEventLoop, *, force: bool = False
475
+ ) -> None:
401
476
  asyncio.run_coroutine_threadsafe(
402
- progress_handler(progress), event_loop
477
+ progress_handler(progress, force=force), event_loop
403
478
  ).result()
404
479
 
405
480
  return p
@@ -7,14 +7,14 @@ 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
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
- from temporalio import workflow
17
+ from temporalio import activity, workflow
18
18
 
19
19
  from .constants import TIKA_METADATA_RESOURCENAME
20
20
 
@@ -323,8 +323,10 @@ 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
+ # TODO: make this one non optional in the next major !
329
+ task_id: str | None
328
330
  label: str | None = None
329
331
  input: Annotated[
330
332
  dict[str, Any] | None,
@@ -336,7 +338,11 @@ class ManifestEntry(DatashareModel, Generic[A], ABC):
336
338
 
337
339
  @classmethod
338
340
  def complete(cls, args: A, label: str | None = None, **kwargs) -> Self:
341
+ task_id = None
342
+ if activity.in_activity():
343
+ task_id = activity.info().workflow_id
339
344
  return cls(
345
+ task_id=task_id,
340
346
  input=args.as_manifest_task_input(),
341
347
  label=label,
342
348
  status=ManifestEntryStatus.COMPLETE,
@@ -345,7 +351,11 @@ class ManifestEntry(DatashareModel, Generic[A], ABC):
345
351
 
346
352
  @classmethod
347
353
  def partial(cls, args: A, label: str | None = None, **kwargs) -> Self:
354
+ task_id = None
355
+ if activity.in_activity():
356
+ task_id = activity.info().workflow_id
348
357
  return cls(
358
+ task_id=task_id,
349
359
  input=args.as_manifest_task_input(),
350
360
  label=label,
351
361
  status=ManifestEntryStatus.PARTIAL,
@@ -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.0rc1"
3
+ version = "0.10.4"
4
4
  description = "Manage Python tasks and local resources in Datashare"
5
5
  authors = [
6
6
  { name = "Clément Doumouro", email = "cdoumouro@icij.org" },