datashare-python 0.8.20__tar.gz → 0.9.0__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.
- {datashare_python-0.8.20 → datashare_python-0.9.0}/PKG-INFO +2 -1
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/config.py +1 -27
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/constants.py +2 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/dependencies.py +25 -1
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/interceptors.py +180 -2
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/objects.py +48 -2
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/types_.py +6 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/utils.py +68 -185
- datashare_python-0.9.0/datashare_python/worker-template.tar.gz +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/worker.py +10 -2
- {datashare_python-0.8.20 → datashare_python-0.9.0}/pyproject.toml +2 -1
- datashare_python-0.8.20/datashare_python/worker-template.tar.gz +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/.gitignore +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/README.md +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/.gitignore +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/__init__.py +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/__main__.py +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/cli/__init__.py +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/cli/project.py +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/cli/task.py +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/cli/utils.py +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/cli/worker.py +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/conftest.py +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/discovery.py +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/exceptions.py +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/logging_.py +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/task_client.py +0 -0
- {datashare_python-0.8.20 → datashare_python-0.9.0}/datashare_python/template.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datashare-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
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
|
-
)
|
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
409
|
-
|
|
254
|
+
activity_fn = activity.defn(activity_fn, name=name)
|
|
255
|
+
|
|
410
256
|
is_async = asyncio.iscoroutinefunction(activity_fn)
|
|
411
257
|
if is_async:
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
)
|
|
258
|
+
|
|
259
|
+
@wraps(activity_fn)
|
|
260
|
+
async def wrapper(*args, **kwargs) -> T:
|
|
261
|
+
return await activity_fn(*args, **kwargs)
|
|
415
262
|
else:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
)
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
)
|
|
Binary file
|
|
@@ -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
|
|
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 = [
|
|
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.
|
|
3
|
+
version = "0.9.0"
|
|
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]
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|