datashare-python 0.8.23__tar.gz → 0.9.2__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.8.23 → datashare_python-0.9.2}/PKG-INFO +2 -1
  2. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/config.py +1 -27
  3. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/constants.py +2 -0
  4. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/dependencies.py +25 -1
  5. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/interceptors.py +180 -2
  6. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/objects.py +48 -2
  7. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/types_.py +6 -0
  8. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/utils.py +68 -185
  9. datashare_python-0.9.2/datashare_python/worker-template.tar.gz +0 -0
  10. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/worker.py +10 -2
  11. {datashare_python-0.8.23 → datashare_python-0.9.2}/pyproject.toml +2 -1
  12. datashare_python-0.8.23/datashare_python/worker-template.tar.gz +0 -0
  13. {datashare_python-0.8.23 → datashare_python-0.9.2}/.gitignore +0 -0
  14. {datashare_python-0.8.23 → datashare_python-0.9.2}/README.md +0 -0
  15. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/.gitignore +0 -0
  16. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/__init__.py +0 -0
  17. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/__main__.py +0 -0
  18. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/cli/__init__.py +0 -0
  19. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/cli/project.py +0 -0
  20. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/cli/task.py +0 -0
  21. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/cli/utils.py +0 -0
  22. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/cli/worker.py +0 -0
  23. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/conftest.py +0 -0
  24. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/discovery.py +0 -0
  25. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/exceptions.py +0 -0
  26. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/logging_.py +0 -0
  27. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/task_client.py +0 -0
  28. {datashare_python-0.8.23 → datashare_python-0.9.2}/datashare_python/template.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datashare-python
3
- Version: 0.8.23
3
+ Version: 0.9.2
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/
@@ -13,6 +13,7 @@ Requires-Dist: alive-progress~=3.2
13
13
  Requires-Dist: hatchling~=1.27
14
14
  Requires-Dist: icij-common[elasticsearch]~=0.8.2
15
15
  Requires-Dist: langcodes~=3.5
16
+ Requires-Dist: lru-dict~=1.4
16
17
  Requires-Dist: nest-asyncio~=1.6
17
18
  Requires-Dist: orjson~=3.11
18
19
  Requires-Dist: pydantic-extra-types[pycountry]>=2.11.1
@@ -6,13 +6,6 @@ from icij_common.es import ESClient
6
6
  from icij_common.pydantic_utils import ICIJSettings
7
7
  from pydantic import PrivateAttr
8
8
  from pydantic_settings import SettingsConfigDict
9
- from temporalio.contrib.pydantic import PydanticJSONPlainPayloadConverter, ToJsonOptions
10
- from temporalio.converter import (
11
- CompositePayloadConverter,
12
- DataConverter,
13
- DefaultPayloadConverter,
14
- JSONPlainPayloadConverter,
15
- )
16
9
  from temporalio.runtime import PrometheusConfig, Runtime, TelemetryConfig
17
10
 
18
11
  import datashare_python
@@ -20,6 +13,7 @@ import datashare_python
20
13
  from .objects import BaseModel
21
14
  from .task_client import DatashareTaskClient
22
15
  from .types_ import TemporalClient
16
+ from .utils import PYDANTIC_DATA_CONVERTER
23
17
 
24
18
  _ALL_LOGGERS = [datashare_python.__name__]
25
19
 
@@ -129,23 +123,3 @@ class WorkerConfig(ICIJSettings, BaseModel):
129
123
 
130
124
  async def to_temporal_client(self) -> TemporalClient:
131
125
  return await self.temporal.to_client()
132
-
133
-
134
- class _PydanticPayloadConverter(CompositePayloadConverter):
135
- def __init__(self) -> None:
136
- json_payload_converter = PydanticJSONPlainPayloadConverter(
137
- ToJsonOptions(exclude_unset=False)
138
- )
139
- super().__init__(
140
- *(
141
- c
142
- if not isinstance(c, JSONPlainPayloadConverter)
143
- else json_payload_converter
144
- for c in DefaultPayloadConverter.default_encoding_payload_converters
145
- )
146
- )
147
-
148
-
149
- PYDANTIC_DATA_CONVERTER = DataConverter(
150
- payload_converter_class=_PydanticPayloadConverter
151
- )
@@ -13,3 +13,5 @@ DEFAULT_NAMESPACE = "datashare-default"
13
13
  METADATA_JSON = "metadata.json"
14
14
 
15
15
  TIKA_METADATA_RESOURCENAME = "tika_metadata_resourcename"
16
+
17
+ DEFAULT_SHARED_RESOURCES_SIZE = 1
@@ -2,7 +2,7 @@ import inspect
2
2
  import logging
3
3
  from asyncio import AbstractEventLoop, iscoroutine
4
4
  from collections.abc import AsyncGenerator, Callable
5
- from contextlib import AsyncExitStack, asynccontextmanager
5
+ from contextlib import AbstractContextManager, AsyncExitStack, asynccontextmanager
6
6
  from contextvars import ContextVar
7
7
  from copy import deepcopy
8
8
  from typing import Any
@@ -12,6 +12,7 @@ from icij_common.es import ESClient
12
12
  from .config import LogLevel, WorkerConfig
13
13
  from .exceptions import DependencyInjectionError
14
14
  from .logging_ import setup_worker_loggers
15
+ from .objects import Shared
15
16
  from .task_client import DatashareTaskClient
16
17
  from .types_ import ContextManagerFactory, TemporalClient
17
18
 
@@ -23,6 +24,7 @@ ES_CLIENT: ContextVar[ESClient] = ContextVar("es_client")
23
24
  TASK_CLIENT: ContextVar[DatashareTaskClient] = ContextVar("task_client")
24
25
  TEMPORAL_CLIENT: ContextVar[TemporalClient] = ContextVar("temporal_client")
25
26
  WORKER_CONFIG: ContextVar[WorkerConfig] = ContextVar("worker_config")
27
+ SHARED: ContextVar[Shared] = ContextVar("shared")
26
28
 
27
29
 
28
30
  def set_event_loop(event_loop: AbstractEventLoop) -> None:
@@ -99,6 +101,20 @@ def lifespan_temporal_client() -> TemporalClient:
99
101
  raise DependencyInjectionError("temporal client") from e
100
102
 
101
103
 
104
+ # Setup shared resources
105
+ async def set_shared_resources(shared: Shared) -> Shared:
106
+ SHARED.set(shared)
107
+ return shared
108
+
109
+
110
+ # Return shared resources
111
+ def lifespan_shared_resources() -> Shared:
112
+ try:
113
+ return SHARED.get()
114
+ except LookupError as e:
115
+ raise DependencyInjectionError("shared resources") from e
116
+
117
+
102
118
  @asynccontextmanager
103
119
  async def with_dependencies(
104
120
  dependencies: list[ContextManagerFactory], **kwargs
@@ -130,3 +146,11 @@ def add_missing_args(fn: Callable, args: dict[str, Any], **kwargs) -> dict[str,
130
146
  args = deepcopy(args)
131
147
  args.update(from_kwargs)
132
148
  return args
149
+
150
+
151
+ # component lifecycle
152
+ def component_teardown(_cache_key: str, component: AbstractContextManager) -> None:
153
+ if not isinstance(component, AbstractContextManager):
154
+ return
155
+
156
+ component.__exit__(None, None, None)
@@ -1,13 +1,31 @@
1
+ import asyncio
2
+ import contextlib
3
+ import dataclasses
1
4
  import secrets
2
- from collections.abc import Generator, Mapping
5
+ from collections.abc import Callable, Generator, Mapping
3
6
  from contextlib import contextmanager
4
7
  from contextvars import ContextVar
5
8
  from copy import deepcopy
6
- from typing import Annotated, Any, NoReturn, Self, TypeVar
9
+ from functools import partial
10
+ from inspect import signature
11
+ from types import UnionType
12
+ from typing import (
13
+ Annotated,
14
+ Any,
15
+ NoReturn,
16
+ Self,
17
+ TypeVar,
18
+ get_args,
19
+ get_origin,
20
+ get_type_hints,
21
+ )
7
22
 
8
23
  from nexusrpc import InputT, OutputT
9
24
  from pydantic import Field
25
+ from temporalio import activity
26
+ from temporalio.activity import _Definition
10
27
  from temporalio.api.common.v1 import Payload
28
+ from temporalio.client import WorkflowHandle
11
29
  from temporalio.converter import DataConverter
12
30
  from temporalio.worker import (
13
31
  ActivityInboundInterceptor,
@@ -34,6 +52,13 @@ from temporalio.workflow import (
34
52
  )
35
53
 
36
54
  from .objects import BaseModel
55
+ from .types_ import ProgressRateHandler, Weight
56
+ from .utils import (
57
+ PROGRESS_HANDLER_ARG,
58
+ PYDANTIC_DATA_CONVERTER,
59
+ ActivityWithProgress,
60
+ ProgressSignal,
61
+ )
37
62
 
38
63
  _TRACEPARENT = "traceparent"
39
64
  _DEFAULT_PAYLOAD_CONVERTER = DataConverter.default.payload_converter
@@ -196,3 +221,156 @@ def _with_trace_context_header(
196
221
  next_ctx.traceparent
197
222
  )
198
223
  return new_obj
224
+
225
+
226
+ class ProgressInterceptor(Interceptor):
227
+ def intercept_activity(
228
+ self,
229
+ next: ActivityInboundInterceptor, # noqa: A002
230
+ ) -> ActivityInboundInterceptor:
231
+ return _ProgressInboundInterceptor(next)
232
+
233
+
234
+ def _parse_progress_weight(act_fn: Callable) -> float:
235
+ hints = get_type_hints(act_fn, include_extras=True)
236
+ hint = hints["progress"]
237
+ annotated_progress = get_origin(hint)
238
+ if annotated_progress is not Annotated:
239
+ return 1.0
240
+ annotated_args = get_args(hint)
241
+ for ann in annotated_args[1:]:
242
+ if isinstance(ann, Weight):
243
+ return ann.value
244
+ return 1.0
245
+
246
+
247
+ async def progress_handler(
248
+ progress: float,
249
+ handle: WorkflowHandle,
250
+ *,
251
+ activity_id: str,
252
+ run_id: str,
253
+ weight: float = 1.0,
254
+ ) -> None:
255
+ signal = ProgressSignal(
256
+ activity_id=activity_id, run_id=run_id, progress=progress, weight=weight
257
+ )
258
+ await handle.signal("update_progress", signal)
259
+
260
+
261
+ def supports_progress(task_fn: Callable) -> bool:
262
+ return any(
263
+ param.name == PROGRESS_HANDLER_ARG
264
+ for param in signature(task_fn).parameters.values()
265
+ )
266
+
267
+
268
+ def _get_progress_handler(act_fn: Callable) -> ProgressRateHandler:
269
+ act = getattr(act_fn, "__self__", None)
270
+ # Weirdly isinstance doesn't work here
271
+ if act is None or not isinstance(act, ActivityWithProgress):
272
+ msg = (
273
+ f"to support progress, activities should inherit from "
274
+ f"{ActivityWithProgress.__name__}."
275
+ )
276
+ raise TypeError(msg)
277
+ weight = _parse_progress_weight(act_fn)
278
+ info = activity.info()
279
+ run_id = info.workflow_run_id
280
+ workflow_id = info.workflow_id
281
+ activity_id = activity.info().activity_id
282
+ client = act._temporal_client
283
+ workflow_handle = client.get_workflow_handle(workflow_id, run_id=run_id)
284
+ handler = partial(
285
+ progress_handler,
286
+ handle=workflow_handle,
287
+ run_id=run_id,
288
+ activity_id=activity_id,
289
+ weight=weight,
290
+ )
291
+ return handler
292
+
293
+
294
+ def _is_progress(t: type) -> bool:
295
+ if t is ProgressRateHandler:
296
+ return True
297
+ return bool(
298
+ isinstance(t, UnionType)
299
+ and any(sub_t is ProgressRateHandler for sub_t in get_args(t))
300
+ )
301
+
302
+
303
+ def _without_progress(arg_types: list[type] | None) -> list[type] | None:
304
+ if arg_types is None:
305
+ return None
306
+ filtered = [t for t in arg_types if not _is_progress(t)]
307
+ return filtered
308
+
309
+
310
+ class _ProgressInboundInterceptor(ActivityInboundInterceptor):
311
+ async def execute_activity(self, input: ExecuteActivityInput) -> Any: # noqa: A002
312
+ if not supports_progress(input.fn):
313
+ return await super().execute_activity(input)
314
+ # The progress args breaks trigger a bypass of the dataloader:
315
+ # https://github.com/temporalio/sdk-python/blob/631ebaf0e20fb214b16589b45627b358048a5d77/temporalio/worker/_activity.py#L600
316
+ # we have to force it here again
317
+ progress_handler = _get_progress_handler(input.fn)
318
+ if input.args:
319
+ data_converter = PYDANTIC_DATA_CONVERTER
320
+ arg_types = _Definition.must_from_callable(input.fn).arg_types
321
+ arg_types = _without_progress(arg_types)
322
+ arg_types = arg_types[: len(input.args)]
323
+ encoded = await data_converter.encode(input.args)
324
+ new_args = await data_converter.decode(encoded, type_hints=arg_types)
325
+ new_args.append(progress_handler)
326
+ else:
327
+ new_args = [progress_handler]
328
+ new_input = dataclasses.replace(input, args=new_args)
329
+ await progress_handler(0.0)
330
+ res = await super().execute_activity(new_input)
331
+ await progress_handler(1.0)
332
+ return res
333
+
334
+
335
+ class HeartbeatInterceptor(Interceptor):
336
+ def __init__(self, n_missed_before_timeout: int = 5):
337
+ self._n_missed_before_timeout = n_missed_before_timeout
338
+
339
+ def intercept_activity(
340
+ self,
341
+ next: ActivityInboundInterceptor, # noqa: A002
342
+ ) -> ActivityInboundInterceptor:
343
+ return _HeartbeatInboundInterceptor(next, self._n_missed_before_timeout)
344
+
345
+
346
+ async def _heartbeat_every(period: float, *details: Any) -> None:
347
+ with contextlib.suppress(RuntimeError, asyncio.TimeoutError):
348
+ activity.heartbeat(*details)
349
+ while True:
350
+ await asyncio.sleep(period)
351
+ with contextlib.suppress(RuntimeError, asyncio.TimeoutError):
352
+ activity.heartbeat(*details)
353
+
354
+
355
+ class _HeartbeatInboundInterceptor(ActivityInboundInterceptor):
356
+ def __init__(
357
+ self,
358
+ next: ActivityInboundInterceptor, # noqa: A002
359
+ n_missed_before_timeout: int = 5,
360
+ ) -> None:
361
+ super().__init__(next)
362
+ self._n_missed_before_timeout = n_missed_before_timeout
363
+
364
+ async def execute_activity(self, input: ExecuteActivityInput) -> Any: # noqa: A002
365
+ heartbeat_timeout = activity.info().heartbeat_timeout
366
+ heartbeat_task = None
367
+ if heartbeat_timeout:
368
+ period = heartbeat_timeout.total_seconds() / self._n_missed_before_timeout
369
+ heartbeat_task = asyncio.create_task(_heartbeat_every(period))
370
+ try:
371
+ activity.heartbeat()
372
+ return await super().execute_activity(input)
373
+ finally:
374
+ if heartbeat_task:
375
+ heartbeat_task.cancel()
376
+ await asyncio.wait([heartbeat_task])
@@ -1,7 +1,8 @@
1
1
  import logging
2
2
  import os
3
+ from asyncio import Lock
3
4
  from collections.abc import Awaitable, Callable
4
- from dataclasses import dataclass
5
+ from dataclasses import InitVar, dataclass, field
5
6
  from datetime import UTC, datetime
6
7
  from enum import StrEnum, unique
7
8
  from io import BytesIO
@@ -9,12 +10,13 @@ from pathlib import Path
9
10
  from typing import Annotated, Any, ClassVar, Literal, Self, TypeVar, cast
10
11
 
11
12
  import langcodes
13
+ from lru import LRU
12
14
  from pydantic_core import PydanticCustomError, ValidationError, core_schema
13
15
  from pydantic_core.core_schema import PlainValidatorFunctionSchema
14
16
  from pydantic_extra_types.language_code import LanguageName
15
17
  from temporalio import workflow
16
18
 
17
- from .constants import TIKA_METADATA_RESOURCENAME
19
+ from .constants import DEFAULT_SHARED_RESOURCES_SIZE, TIKA_METADATA_RESOURCENAME
18
20
 
19
21
  with workflow.unsafe.imports_passed_through():
20
22
  from icij_common.es import (
@@ -367,3 +369,47 @@ class TaskGroup:
367
369
  @classmethod
368
370
  def python(cls) -> Self:
369
371
  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)
@@ -1,5 +1,6 @@
1
1
  from collections.abc import Coroutine
2
2
  from contextlib import AbstractAsyncContextManager, AbstractContextManager
3
+ from dataclasses import dataclass
3
4
  from typing import Protocol
4
5
 
5
6
  from temporalio.client import Client
@@ -12,6 +13,11 @@ class ProgressRateHandler(Protocol):
12
13
  pass
13
14
 
14
15
 
16
+ @dataclass
17
+ class Weight:
18
+ value: float
19
+
20
+
15
21
  class RawProgressHandler(Protocol):
16
22
  async def __call__(self, iteration: int) -> None:
17
23
  pass
@@ -1,18 +1,15 @@
1
1
  import asyncio
2
2
  import contextlib
3
- import contextvars
4
3
  import inspect
5
4
  import json
6
5
  import os
7
6
  import shutil
8
- import threading
9
- from collections.abc import Awaitable, Callable, Coroutine, Iterable
7
+ from collections.abc import Callable, Coroutine, Iterable, Sequence
10
8
  from copy import deepcopy
11
9
  from dataclasses import dataclass
12
10
  from datetime import timedelta
13
- from functools import partial, wraps
11
+ from functools import wraps
14
12
  from hashlib import sha256
15
- from inspect import signature
16
13
  from io import BytesIO
17
14
  from pathlib import Path
18
15
  from typing import Any, ParamSpec, TypeVar
@@ -20,9 +17,18 @@ from uuid import uuid4
20
17
 
21
18
  import nest_asyncio
22
19
  import temporalio
20
+ from pydantic import ValidationError
23
21
  from temporalio import activity, workflow
24
- from temporalio.client import Client, WorkflowHandle
22
+ from temporalio.api.common.v1 import Payload
23
+ from temporalio.client import Client
25
24
  from temporalio.common import RetryPolicy, SearchAttributeKey
25
+ from temporalio.contrib.pydantic import PydanticJSONPlainPayloadConverter, ToJsonOptions
26
+ from temporalio.converter import (
27
+ CompositePayloadConverter,
28
+ DataConverter,
29
+ DefaultPayloadConverter,
30
+ JSONPlainPayloadConverter,
31
+ )
26
32
  from temporalio.exceptions import ApplicationError
27
33
 
28
34
  from .constants import METADATA_JSON
@@ -98,9 +104,14 @@ class WorkflowWithProgress:
98
104
 
99
105
  def _retry_policy_with_default(retry_policy: RetryPolicy | None) -> RetryPolicy:
100
106
  if retry_policy is None:
101
- retry_policy = RetryPolicy(non_retryable_error_types=[])
107
+ retry_policy = RetryPolicy(non_retryable_error_types=[], maximum_attempts=3)
102
108
  retry_policy = deepcopy(retry_policy)
103
- non_retryable_error_types = set(retry_policy.non_retryable_error_types)
109
+ non_retryable_error_types = (
110
+ retry_policy.non_retryable_error_types
111
+ if retry_policy.non_retryable_error_types is not None
112
+ else []
113
+ )
114
+ non_retryable_error_types = set(non_retryable_error_types)
104
115
  non_retryable_error_types.update(_NEVER_RETRIABLES)
105
116
  retry_policy.non_retryable_error_types = list(non_retryable_error_types)
106
117
  return retry_policy
@@ -130,167 +141,6 @@ async def execute_activity(
130
141
  )
131
142
 
132
143
 
133
- async def progress_handler(
134
- progress: float,
135
- handle: WorkflowHandle,
136
- *,
137
- activity_id: str,
138
- run_id: str,
139
- weight: float = 1.0,
140
- ) -> None:
141
- signal = ProgressSignal(
142
- activity_id=activity_id, run_id=run_id, progress=progress, weight=weight
143
- )
144
- await handle.signal("update_progress", signal)
145
- with contextlib.suppress(RuntimeError, asyncio.TimeoutError):
146
- activity.heartbeat()
147
-
148
-
149
- def get_activity_progress_handler_async(
150
- client: Client, weight: float
151
- ) -> ProgressRateHandler:
152
- info = activity.info()
153
- run_id = info.workflow_run_id
154
- workflow_id = info.workflow_id
155
- activity_id = activity.info().activity_id
156
- workflow_handle = client.get_workflow_handle(workflow_id, run_id=run_id)
157
- handler = partial(
158
- progress_handler,
159
- handle=workflow_handle,
160
- run_id=run_id,
161
- activity_id=activity_id,
162
- weight=weight,
163
- )
164
- return handler
165
-
166
-
167
- def supports_progress(task_fn: Callable) -> bool:
168
- return any(
169
- param.name == PROGRESS_HANDLER_ARG
170
- for param in signature(task_fn).parameters.values()
171
- )
172
-
173
-
174
- def with_progress(weight: float = 1.0) -> Callable[P, T]:
175
- if isinstance(weight, Callable):
176
- return with_progress(weight=1)(weight)
177
-
178
- def decorator(activity_fn: Callable[P, T]) -> Callable[P, T]:
179
- # TODO: handle the fact activities should have only positional args...
180
- if asyncio.iscoroutinefunction(activity_fn):
181
-
182
- @wraps(activity_fn)
183
- async def wrapper(self: ActivityWithProgress, *args: P.args) -> T:
184
- if not isinstance(self, ActivityWithProgress):
185
- msg = (
186
- f"{with_progress.__name__} decorator is meant to be used on "
187
- f"activities defined as an {ActivityWithProgress.__name__}"
188
- f" method, expected a {ActivityWithProgress.__name__} as first"
189
- f" argument, found {self}"
190
- )
191
- raise TypeError(msg)
192
- handler = get_activity_progress_handler_async(
193
- client=self._temporal_client, weight=weight
194
- )
195
- await handler(0.0)
196
- res = await activity_fn(self, *args, progress=handler)
197
- await handler(1.0)
198
- return res
199
-
200
- else:
201
-
202
- @wraps(activity_fn)
203
- def wrapper(self: ActivityWithProgress, *args: P.args) -> T:
204
- if not isinstance(self, ActivityWithProgress):
205
- msg = (
206
- f"{with_progress.__name__} decorator is meant to be used on "
207
- f"activities defined as an {ActivityWithProgress.__name__}"
208
- f" method, expected a {ActivityWithProgress.__name__} as first"
209
- f" argument, found {self}"
210
- )
211
- raise TypeError(msg)
212
- handler = get_activity_progress_handler_async(
213
- client=self._temporal_client, weight=weight
214
- )
215
- event_loop = self._event_loop
216
- asyncio.run_coroutine_threadsafe(handler(0.0), event_loop).result()
217
- res = activity_fn(self, *args, progress=handler)
218
- asyncio.run_coroutine_threadsafe(handler(1.0), event_loop).result()
219
- return res
220
-
221
- return wrapper
222
-
223
- return decorator
224
-
225
-
226
- def with_async_heartbeat(
227
- activity_fn: Callable[P, Awaitable[T]], n_missed_before_timeout: int
228
- ) -> Callable[P, Awaitable[T]]:
229
- # Copied from
230
- # https://github.com/temporalio/samples-python/blob/main/custom_decorator/activity_utils.py
231
- @wraps(activity_fn)
232
- async def wrapper(*args, **kwargs) -> T:
233
- heartbeat_timeout = activity.info().heartbeat_timeout
234
- heartbeat_task = None
235
- if heartbeat_timeout:
236
- period = heartbeat_timeout.total_seconds() / n_missed_before_timeout
237
- heartbeat_task = asyncio.create_task(_async_heartbeat_every(period))
238
- try:
239
- activity.heartbeat()
240
- return await activity_fn(*args, **kwargs)
241
- finally:
242
- if heartbeat_task:
243
- heartbeat_task.cancel()
244
- await asyncio.wait([heartbeat_task])
245
-
246
- return wrapper
247
-
248
-
249
- async def _async_heartbeat_every(period: float, *details: Any) -> None:
250
- with contextlib.suppress(RuntimeError, asyncio.TimeoutError):
251
- activity.heartbeat(*details)
252
- while True:
253
- await asyncio.sleep(period)
254
- with contextlib.suppress(RuntimeError, asyncio.TimeoutError):
255
- activity.heartbeat(*details)
256
-
257
-
258
- def with_sync_heartbeat(
259
- activity_fn: Callable[P, T], n_missed_before_timeout: int
260
- ) -> Callable[P, T]:
261
- @wraps(activity_fn)
262
- def wrapper(*args, **kwargs) -> T:
263
- heartbeat_timeout = activity.info().heartbeat_timeout
264
- heartbeat_thread, stop_event = None, None
265
- if heartbeat_timeout:
266
- period = heartbeat_timeout.total_seconds() / n_missed_before_timeout
267
- ctx = contextvars.copy_context()
268
- run_args = (_sync_heartbeat_every, period, threading.Event())
269
- heartbeat_thread, stop_event = (
270
- threading.Thread(target=ctx.run, args=run_args),
271
- run_args[-1],
272
- )
273
- heartbeat_thread.start()
274
- try:
275
- return activity_fn(*args, **kwargs)
276
- finally:
277
- if heartbeat_thread:
278
- stop_event.set()
279
- heartbeat_thread.join()
280
-
281
- return wrapper
282
-
283
-
284
- def _sync_heartbeat_every(
285
- period: float, stop_event: threading.Event, *details: Any
286
- ) -> None:
287
- with contextlib.suppress(RuntimeError, asyncio.TimeoutError):
288
- activity.heartbeat(*details)
289
- while not stop_event.wait(period):
290
- with contextlib.suppress(RuntimeError, asyncio.TimeoutError):
291
- activity.heartbeat(*details)
292
-
293
-
294
144
  def positional_args_only(activity_fn: Callable[P, T]) -> Callable[P, T]:
295
145
  sig = inspect.signature(activity_fn)
296
146
 
@@ -395,29 +245,27 @@ def with_retriables(
395
245
 
396
246
 
397
247
  def activity_defn(
398
- name: str,
399
- progress_weight: float = 1.0,
400
- retriables: set[type[Exception]] = None,
401
- n_missed_heartbeats_before_timeout: int = 5,
248
+ name: str, retriables: set[type[Exception]] = None
402
249
  ) -> Callable[[Callable[P, T]], Callable[P, T]]:
250
+
403
251
  def decorator(activity_fn: Callable[P, T]) -> Callable[P, T]:
404
- # TODO: some of these could probably be reimplemented more elegantly using
405
- # temporal interceptors: https://docs.temporal.io/develop/python/workers/interceptors
406
252
  activity_fn = positional_args_only(activity_fn)
407
253
  activity_fn = with_retriables(retriables)(activity_fn)
408
- if supports_progress(activity_fn):
409
- activity_fn = with_progress(progress_weight)(activity_fn)
254
+ activity_fn = activity.defn(activity_fn, name=name)
255
+
410
256
  is_async = asyncio.iscoroutinefunction(activity_fn)
411
257
  if is_async:
412
- activity_fn = with_async_heartbeat(
413
- activity_fn, n_missed_heartbeats_before_timeout
414
- )
258
+
259
+ @wraps(activity_fn)
260
+ async def wrapper(*args, **kwargs) -> T:
261
+ return await activity_fn(*args, **kwargs)
415
262
  else:
416
- activity_fn = with_sync_heartbeat(
417
- activity_fn, n_missed_heartbeats_before_timeout
418
- )
419
- activity_fn = activity.defn(activity_fn, name=name)
420
- return activity_fn
263
+
264
+ @wraps(activity_fn)
265
+ def wrapper(*args, **kwargs) -> T:
266
+ return activity_fn(*args, **kwargs)
267
+
268
+ return wrapper
421
269
 
422
270
  return decorator
423
271
 
@@ -488,6 +336,7 @@ def write_artifact(root: Path, artifact: DocArtifact) -> Path:
488
336
  with artifact_path.open("wb") as f:
489
337
  f.write(artifact.artifact.read())
490
338
  case Path():
339
+ artifact_path.unlink(missing_ok=True)
491
340
  shutil.move(artifact.artifact, artifact_path)
492
341
  case _:
493
342
  msg = f"unsupported artifact type: {artifact.artifact.__class__.__name__}"
@@ -601,3 +450,37 @@ def read_jsonl(path: Path) -> Iterable[dict]:
601
450
  line = line.strip() # noqa: PLW2901
602
451
  if line:
603
452
  yield json.loads(line)
453
+
454
+
455
+ class _PydanticPayloadConverter(CompositePayloadConverter):
456
+ def __init__(self) -> None:
457
+ json_payload_converter = PydanticJSONPlainPayloadConverter(
458
+ ToJsonOptions(exclude_unset=False)
459
+ )
460
+ super().__init__(
461
+ *(
462
+ c
463
+ if not isinstance(c, JSONPlainPayloadConverter)
464
+ else json_payload_converter
465
+ for c in DefaultPayloadConverter.default_encoding_payload_converters
466
+ )
467
+ )
468
+
469
+ def from_payloads(
470
+ self, payloads: Sequence[Payload], type_hints: list[type] | None = None
471
+ ) -> list[Any]:
472
+ try:
473
+ return super().from_payloads(payloads, type_hints)
474
+ except (TypeError, ValidationError) as e:
475
+ raise fatal_error_from_exception(e) from e
476
+
477
+ def to_payloads(self, values: Sequence[Any]) -> list[Payload]:
478
+ try:
479
+ return super().to_payloads(values)
480
+ except (TypeError, ValidationError) as e:
481
+ raise fatal_error_from_exception(e) from e
482
+
483
+
484
+ PYDANTIC_DATA_CONVERTER = DataConverter(
485
+ payload_converter_class=_PydanticPayloadConverter
486
+ )
@@ -21,7 +21,11 @@ from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner
21
21
  from .config import WorkerConfig
22
22
  from .dependencies import with_dependencies
23
23
  from .discovery import Activity
24
- from .interceptors import TraceContextInterceptor
24
+ from .interceptors import (
25
+ HeartbeatInterceptor,
26
+ ProgressInterceptor,
27
+ TraceContextInterceptor,
28
+ )
25
29
  from .types_ import ContextManagerFactory, TemporalClient
26
30
 
27
31
  logger = logging.getLogger(__name__)
@@ -91,7 +95,11 @@ def datashare_worker(
91
95
  max_concurrent_activities = 1
92
96
  if workflows:
93
97
  logger.warning(_SEPARATE_IO_AND_CPU_WORKERS)
94
- interceptors = [TraceContextInterceptor()]
98
+ interceptors = [
99
+ TraceContextInterceptor(),
100
+ ProgressInterceptor(),
101
+ HeartbeatInterceptor(),
102
+ ]
95
103
  wf_runner = SandboxedWorkflowRunner() if sandboxed else UnsandboxedWorkflowRunner()
96
104
  return DatashareWorker(
97
105
  client,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datashare-python"
3
- version = "0.8.23"
3
+ version = "0.9.2"
4
4
  description = "Manage Python tasks and local resources in Datashare"
5
5
  authors = [
6
6
  { name = "Clément Doumouro", email = "cdoumouro@icij.org" },
@@ -23,6 +23,7 @@ dependencies = [
23
23
  "temporalio~=1.23",
24
24
  "typer>=0.15.4,<0.25.1",
25
25
  "tomlkit~=0.14.0",
26
+ "lru-dict~=1.4",
26
27
  ]
27
28
 
28
29
  [project.urls]