datashare-python 0.9.8__tar.gz → 0.10.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.
Files changed (29) hide show
  1. {datashare_python-0.9.8 → datashare_python-0.10.0}/PKG-INFO +4 -3
  2. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/cli/task.py +2 -2
  3. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/cli/worker.py +1 -1
  4. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/config.py +17 -7
  5. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/conftest.py +178 -45
  6. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/constants.py +1 -2
  7. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/dependencies.py +0 -16
  8. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/interceptors.py +1 -1
  9. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/logging_.py +1 -1
  10. datashare_python-0.10.0/datashare_python/mimetypes_.py +1105 -0
  11. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/objects.py +197 -68
  12. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/utils.py +350 -47
  13. datashare_python-0.10.0/datashare_python/worker-template.tar.gz +0 -0
  14. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/worker.py +14 -3
  15. {datashare_python-0.9.8 → datashare_python-0.10.0}/pyproject.toml +10 -5
  16. datashare_python-0.9.8/datashare_python/worker-template.tar.gz +0 -0
  17. {datashare_python-0.9.8 → datashare_python-0.10.0}/.gitignore +0 -0
  18. {datashare_python-0.9.8 → datashare_python-0.10.0}/README.md +0 -0
  19. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/.gitignore +0 -0
  20. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/__init__.py +0 -0
  21. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/__main__.py +0 -0
  22. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/cli/__init__.py +0 -0
  23. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/cli/project.py +0 -0
  24. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/cli/utils.py +0 -0
  25. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/discovery.py +0 -0
  26. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/exceptions.py +0 -0
  27. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/task_client.py +0 -0
  28. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/template.py +0 -0
  29. {datashare_python-0.9.8 → datashare_python-0.10.0}/datashare_python/types_.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datashare-python
3
- Version: 0.9.8
3
+ Version: 0.10.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/
@@ -8,15 +8,16 @@ Project-URL: Repository, https://github.com/ICIJ/datashare-python
8
8
  Project-URL: Issues, https://github.com/ICIJ/datashare-python/issues
9
9
  Author-email: Clément Doumouro <cdoumouro@icij.org>, Clément Doumouro <clement.doumouro@gmail.com>, Lion Summerbell <lsummerbell@icij.org>
10
10
  Requires-Python: <4,>=3.11
11
+ Requires-Dist: aiofile~=3.11
11
12
  Requires-Dist: aiohttp~=3.11
12
13
  Requires-Dist: alive-progress~=3.2
13
14
  Requires-Dist: hatchling~=1.27
14
- Requires-Dist: icij-common[elasticsearch]~=0.8.2
15
+ Requires-Dist: icij-common[elasticsearch]~=0.8.3
15
16
  Requires-Dist: langcodes~=3.5
16
17
  Requires-Dist: lru-dict~=1.4
17
18
  Requires-Dist: pydantic-extra-types[pycountry]>=2.11.1
18
19
  Requires-Dist: python-json-logger~=4.0
19
20
  Requires-Dist: pyyaml~=6.0
20
- Requires-Dist: temporalio~=1.23
21
+ Requires-Dist: temporalio~=1.31
21
22
  Requires-Dist: tomlkit~=0.14.0
22
23
  Requires-Dist: typer<0.25.1,>=0.15.4
@@ -28,7 +28,7 @@ _START_HELP = "creates a new task and start it"
28
28
  _TASK_ID_HELP = "task ID"
29
29
  _WATCH_HELP = "watch a task until it's complete"
30
30
 
31
- TaskArgs = str
31
+ StrTaskArgs = str
32
32
 
33
33
  task_app = AsyncTyper(name="task")
34
34
 
@@ -36,7 +36,7 @@ task_app = AsyncTyper(name="task")
36
36
  @task_app.async_command(help=_START_HELP)
37
37
  async def start(
38
38
  name: Annotated[str, typer.Argument(help=_NAME_HELP)],
39
- args: Annotated[TaskArgs, typer.Argument(help=_ARGS_HELP)] = None,
39
+ args: Annotated[StrTaskArgs, typer.Argument(help=_ARGS_HELP)] = None,
40
40
  group: Annotated[
41
41
  str | None,
42
42
  typer.Option("--group", "-g", help=_GROUP_HELP),
@@ -78,7 +78,7 @@ to the documentation to learn how to do so."""
78
78
 
79
79
 
80
80
  @worker_app.async_command(help=_START_WORKER_HELP)
81
- async def start(
81
+ async def start( # noqa: PLR0917
82
82
  queue: Annotated[str, typer.Option("--queue", "-q", help=_WORKER_QUEUE_HELP)],
83
83
  workflows: Annotated[
84
84
  list[str] | None,
@@ -1,5 +1,4 @@
1
1
  from enum import StrEnum
2
- from pathlib import Path
3
2
  from typing import Literal
4
3
 
5
4
  from icij_common.es import ESClient
@@ -10,10 +9,10 @@ from temporalio.runtime import PrometheusConfig, Runtime, TelemetryConfig
10
9
 
11
10
  import datashare_python
12
11
 
13
- from .objects import BaseModel
12
+ from .objects import BaseModel, WorkerPaths
14
13
  from .task_client import DatashareTaskClient
15
14
  from .types_ import TemporalClient
16
- from .utils import PYDANTIC_DATA_CONVERTER
15
+ from .utils import PYDANTIC_DATA_CONVERTER, SharedResources, close_cm_callback
17
16
 
18
17
  _ALL_LOGGERS = [datashare_python.__name__]
19
18
 
@@ -94,6 +93,19 @@ class LoggingConfig(BaseModel):
94
93
  loggers: dict[str, LogLevel]
95
94
 
96
95
 
96
+ class ResourceCacheConfig(BaseModel):
97
+ size: int = 1
98
+ exit_context_managers: bool = True
99
+
100
+ def to_resource_cache(self) -> SharedResources:
101
+ eviction_callback = None
102
+ if self.exit_context_managers:
103
+ eviction_callback = close_cm_callback
104
+ return SharedResources(
105
+ cache_size=self.size, eviction_callback=eviction_callback
106
+ )
107
+
108
+
97
109
  _DEFAULT_LOGGERS = {datashare_python.__name__: "INFO"}
98
110
  _DEFAULT_LOGGING_CONFIG = LoggingConfig(
99
111
  format=LogFormat.DEFAULT, loggers=_DEFAULT_LOGGERS
@@ -109,11 +121,9 @@ class WorkerConfig(ICIJSettings, BaseModel):
109
121
  elasticsearch: ESClientConfig = ESClientConfig()
110
122
  temporal: TemporalClientConfig = TemporalClientConfig()
111
123
 
112
- max_concurrent_io_activities: int = 5
124
+ max_concurrent_activities: int = 5
113
125
 
114
- docs_root: Path | None = None
115
- artifacts_root: Path | None = None
116
- workdir: Path | None = None
126
+ paths: WorkerPaths | None = None
117
127
 
118
128
  def to_es_client(self) -> ESClient:
119
129
  return self.elasticsearch.to_es_client(self.datashare.api_key)
@@ -1,15 +1,22 @@
1
+ import asyncio
2
+ import faulthandler
3
+ import logging
4
+ import multiprocessing
1
5
  import shutil
6
+ from asyncio import AbstractEventLoop
2
7
  from collections.abc import AsyncGenerator, Generator, Sequence
8
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
9
+ from multiprocessing import Event
3
10
  from pathlib import Path
4
11
 
5
12
  import aiohttp
6
- import nest_asyncio
7
13
  import pytest
8
14
  from elasticsearch._async.helpers import async_streaming_bulk
9
15
  from icij_common.es import DOC_ROOT_ID, ES_DOCUMENT_TYPE, ID, ESClient
10
16
  from icij_common.test_utils import reset_env # noqa: F401
11
17
  from pytest_asyncio import is_async_test
12
18
  from temporalio import workflow
19
+ from temporalio.runtime import PrometheusConfig, Runtime, TelemetryConfig
13
20
  from temporalio.service import RPCError, RPCStatusCode
14
21
 
15
22
  from datashare_python.config import (
@@ -19,17 +26,14 @@ from datashare_python.config import (
19
26
  TemporalClientConfig,
20
27
  WorkerConfig,
21
28
  )
22
- from datashare_python.dependencies import (
23
- lifespan_es_client,
24
- lifespan_task_client,
25
- set_es_client,
26
- set_task_client,
27
- with_dependencies,
28
- )
29
+ from datashare_python.discovery import discover
29
30
  from datashare_python.objects import Document, TaskState
30
31
  from datashare_python.task_client import DatashareTaskClient
31
- from datashare_python.types_ import ContextManagerFactory, TemporalClient
32
- from datashare_python.utils import activity_defn
32
+ from datashare_python.types_ import TemporalClient
33
+ from datashare_python.utils import PYDANTIC_DATA_CONVERTER, activity_defn
34
+ from datashare_python.worker import create_worker_id, worker_context
35
+
36
+ logger = logging.getLogger(__name__)
33
37
 
34
38
  RABBITMQ_TEST_PORT = 5672
35
39
  RABBITMQ_TEST_HOST = "localhost"
@@ -83,11 +87,6 @@ class MockedWorkflow:
83
87
  return None
84
88
 
85
89
 
86
- @pytest.fixture(scope="session")
87
- def test_deps() -> list[ContextManagerFactory]:
88
- return [set_es_client, set_task_client]
89
-
90
-
91
90
  @pytest.fixture(scope="session")
92
91
  def test_worker_config() -> WorkerConfig:
93
92
  logging_config = LoggingConfig(
@@ -113,25 +112,8 @@ def test_worker_config_path(test_worker_config: WorkerConfig, tmpdir: Path) -> P
113
112
 
114
113
 
115
114
  @pytest.fixture(scope="session")
116
- async def worker_lifetime_deps(
117
- test_deps: list[ContextManagerFactory], test_worker_config: WorkerConfig
118
- ) -> AsyncGenerator[None, None]:
119
- worker_id = "test-worker-id"
120
- ctx = "test application"
121
- async with with_dependencies(
122
- test_deps,
123
- ctx=ctx,
124
- worker_id=worker_id,
125
- worker_config=test_worker_config,
126
- ):
127
- yield
128
-
129
-
130
- @pytest.fixture(scope="session")
131
- async def test_es_client_session(
132
- worker_lifetime_deps, # noqa: ANN001, ARG001
133
- ) -> ESClient:
134
- es = lifespan_es_client()
115
+ async def test_es_client_session(test_worker_config: WorkerConfig) -> ESClient:
116
+ es = test_worker_config.to_es_client()
135
117
  await es.indices.delete(index="_all")
136
118
  await es.indices.create(index=TEST_PROJECT, body=_INDEX_BODY)
137
119
  return es
@@ -147,9 +129,9 @@ async def test_es_client(test_es_client_session: ESClient) -> ESClient:
147
129
 
148
130
  @pytest.fixture(scope="session")
149
131
  async def test_task_client_session(
150
- worker_lifetime_deps, # noqa: ANN001, ARG001
132
+ test_worker_config: WorkerConfig,
151
133
  ) -> AsyncGenerator[DatashareTaskClient, None]:
152
- task_client = lifespan_task_client()
134
+ task_client = test_worker_config.to_task_client()
153
135
  async with task_client:
154
136
  user, key = await task_client.create_api_key()
155
137
  task_client.authenticate(user, key)
@@ -188,17 +170,24 @@ async def test_temporal_client(
188
170
 
189
171
 
190
172
  @pytest.fixture
191
- async def populate_es(
192
- test_es_client: ESClient,
173
+ async def indexed_docs( # noqa: PLR0917
193
174
  doc_0: Document,
194
175
  doc_1: Document,
195
176
  doc_2: Document,
196
177
  doc_3: Document,
197
178
  ) -> list[Document]:
198
- docs = [doc_0, doc_1, doc_2, doc_3]
199
- async for _ in index_docs(test_es_client, docs=docs, index_name=TEST_PROJECT):
179
+ return [doc_0, doc_1, doc_2, doc_3]
180
+
181
+
182
+ @pytest.fixture
183
+ async def populate_es(
184
+ test_es_client: ESClient, indexed_docs: list[Document]
185
+ ) -> list[Document]:
186
+ async for _ in index_docs(
187
+ test_es_client, docs=indexed_docs, index_name=TEST_PROJECT
188
+ ):
200
189
  pass
201
- return docs
190
+ return indexed_docs
202
191
 
203
192
 
204
193
  def index_docs_ops(
@@ -309,11 +298,155 @@ async def all_done(task_client: DatashareTaskClient, not_done: list[str]) -> boo
309
298
 
310
299
  @pytest.fixture # noqa: F405
311
300
  def typer_asyncio_patch() -> None:
301
+ import nest_asyncio # noqa: PLC0415
302
+
312
303
  nest_asyncio.apply()
313
304
 
314
305
 
315
306
  def clear_dirs(config: WorkerConfig) -> None:
316
- shutil.rmtree(str(config.artifacts_root))
317
- config.artifacts_root.mkdir(parents=True, exist_ok=True)
318
- shutil.rmtree(str(config.workdir))
319
- config.workdir.mkdir(parents=True, exist_ok=True)
307
+ shutil.rmtree(str(config.paths.filesystem))
308
+ config.paths.filesystem.mkdir(parents=True, exist_ok=True)
309
+ shutil.rmtree(str(config.paths.artifacts))
310
+ config.paths.artifacts.mkdir(parents=True, exist_ok=True)
311
+ shutil.rmtree(str(config.paths.workdir))
312
+ config.paths.workdir.mkdir(parents=True, exist_ok=True)
313
+
314
+
315
+ def dev_worker_context(
316
+ worker_id_prefix: str,
317
+ *,
318
+ is_async: bool,
319
+ client: TemporalClient,
320
+ activities: list[str] | None = None,
321
+ workflows: list[str] | None = None,
322
+ worker_config: WorkerConfig,
323
+ event_loop: AbstractEventLoop | None = None,
324
+ task_queue: str,
325
+ dependencies: str | None = None,
326
+ sandboxed: bool = True,
327
+ ) -> AbstractAsyncContextManager[None]:
328
+ worker_id = create_worker_id(worker_id_prefix)
329
+ if not is_async:
330
+ return _run_worker_in_separate_process(
331
+ worker_id=worker_id,
332
+ activities=activities,
333
+ workflows=workflows,
334
+ worker_config=worker_config.model_dump(),
335
+ task_queue=task_queue,
336
+ dependencies=dependencies,
337
+ sandboxed=sandboxed,
338
+ )
339
+ registered_wfs, registered_acts, registered_deps, worker_config_cls = discover(
340
+ workflows,
341
+ act_names=activities,
342
+ deps_name=dependencies,
343
+ )
344
+ cm = _erase_type(
345
+ worker_context(
346
+ worker_id,
347
+ activities=registered_acts,
348
+ workflows=registered_wfs,
349
+ dependencies=registered_deps,
350
+ worker_config=worker_config,
351
+ client=client,
352
+ event_loop=event_loop,
353
+ task_queue=task_queue,
354
+ )
355
+ )
356
+ return cm
357
+
358
+
359
+ @asynccontextmanager
360
+ async def _erase_type(cm: AbstractAsyncContextManager) -> AsyncGenerator[None, None]:
361
+ async with cm:
362
+ yield
363
+
364
+
365
+ @asynccontextmanager
366
+ async def _run_worker_in_separate_process(
367
+ worker_id: str,
368
+ *,
369
+ activities: list[str] | None = None,
370
+ workflows: list[str] | None = None,
371
+ worker_config: dict,
372
+ task_queue: str,
373
+ dependencies: str | None = None,
374
+ sandboxed: bool = True,
375
+ ) -> AsyncGenerator[None, None]:
376
+ ctx = multiprocessing.get_context("spawn")
377
+ shutdown_event = ctx.Event()
378
+ faulthandler.enable()
379
+ kwargs = {
380
+ "worker_id": worker_id,
381
+ "activities": activities,
382
+ "workflows": workflows,
383
+ "worker_config": worker_config,
384
+ "task_queue": task_queue,
385
+ "dependencies": dependencies,
386
+ "sandboxed": sandboxed,
387
+ "shutdown_event": shutdown_event,
388
+ }
389
+ p = ctx.Process(target=_run_worker_process, kwargs=kwargs)
390
+ p.start()
391
+ try:
392
+ yield
393
+ finally:
394
+ shutdown_event.set()
395
+ p.join()
396
+ if p.is_alive():
397
+ p.terminate()
398
+
399
+
400
+ def _run_worker_process(
401
+ worker_id: str,
402
+ *,
403
+ activities: list[str] | None = None,
404
+ workflows: list[str] | None = None,
405
+ worker_config: dict,
406
+ event_loop: AbstractEventLoop | None = None,
407
+ task_queue: str,
408
+ dependencies: str | None = None,
409
+ sandboxed: bool = True,
410
+ shutdown_event: Event,
411
+ ) -> None:
412
+ async def _run() -> None:
413
+ registered_wfs, registered_acts, registered_deps, worker_config_cls = discover(
414
+ workflows,
415
+ act_names=activities,
416
+ deps_name=dependencies,
417
+ )
418
+ loaded_config = worker_config_cls.model_validate(worker_config)
419
+ temporal_config = loaded_config.temporal
420
+ runtime = Runtime(telemetry=TelemetryConfig())
421
+ # We don't use config.to_temporal_client since we get a problem with the
422
+ # temporal runtime not being properly recreated, we create it explicitely
423
+ if temporal_config.prometheus_host is not None:
424
+ telemetry_config = TelemetryConfig(
425
+ metrics=PrometheusConfig(bind_address="0.0.0.0:9000")
426
+ )
427
+ runtime = Runtime(telemetry=telemetry_config)
428
+ client = await TemporalClient.connect(
429
+ target_host=temporal_config.host,
430
+ namespace=temporal_config.namespace,
431
+ runtime=runtime,
432
+ data_converter=PYDANTIC_DATA_CONVERTER,
433
+ )
434
+ worker_ctx = worker_context(
435
+ worker_id,
436
+ activities=registered_acts,
437
+ workflows=registered_wfs,
438
+ worker_config=loaded_config,
439
+ client=client,
440
+ event_loop=event_loop,
441
+ task_queue=task_queue,
442
+ dependencies=registered_deps,
443
+ sandboxed=sandboxed,
444
+ )
445
+ async with worker_ctx as worker:
446
+ aws = [
447
+ asyncio.create_task(asyncio.to_thread(shutdown_event.wait)),
448
+ asyncio.create_task(worker.is_done()),
449
+ ]
450
+ await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED)
451
+
452
+ asyncio.run(_run())
@@ -11,7 +11,6 @@ DEFAULT_DS_ADDRESS = "http://localhost:8080"
11
11
  DEFAULT_NAMESPACE = "datashare-default"
12
12
 
13
13
  METADATA_JSON = "metadata.json"
14
+ MANIFEST_JSON = "manifest.json"
14
15
 
15
16
  TIKA_METADATA_RESOURCENAME = "tika_metadata_resourcename"
16
-
17
- DEFAULT_SHARED_RESOURCES_SIZE = 1
@@ -12,7 +12,6 @@ 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
16
15
  from .task_client import DatashareTaskClient
17
16
  from .types_ import ContextManagerFactory, TemporalClient
18
17
 
@@ -24,7 +23,6 @@ ES_CLIENT: ContextVar[ESClient] = ContextVar("es_client")
24
23
  TASK_CLIENT: ContextVar[DatashareTaskClient] = ContextVar("task_client")
25
24
  TEMPORAL_CLIENT: ContextVar[TemporalClient] = ContextVar("temporal_client")
26
25
  WORKER_CONFIG: ContextVar[WorkerConfig] = ContextVar("worker_config")
27
- SHARED: ContextVar[Shared] = ContextVar("shared")
28
26
 
29
27
 
30
28
  def set_event_loop(event_loop: AbstractEventLoop) -> None:
@@ -101,20 +99,6 @@ def lifespan_temporal_client() -> TemporalClient:
101
99
  raise DependencyInjectionError("temporal client") from e
102
100
 
103
101
 
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
-
118
102
  @asynccontextmanager
119
103
  async def with_dependencies(
120
104
  dependencies: list[ContextManagerFactory], **kwargs
@@ -219,7 +219,7 @@ def _trace_context(headers: Mapping[str, Payload]) -> Generator[None, None, None
219
219
  InputWithHeaders = TypeVar("InputWithHeaders")
220
220
 
221
221
 
222
- def _with_trace_context_header(
222
+ def _with_trace_context_header[InputWithHeaders](
223
223
  input_with_headers: InputWithHeaders,
224
224
  ) -> InputWithHeaders:
225
225
  ctx = get_trace_context()
@@ -132,7 +132,7 @@ def _encode_value(value: Any) -> str:
132
132
  return "true" if value else "false"
133
133
  if isinstance(value, numbers.Number):
134
134
  return str(value)
135
- return json.dumps(value).decode()
135
+ return json.dumps(value)
136
136
 
137
137
 
138
138
  def _json_formatter(datefmt: str) -> BaseJsonFormatter: