extract-python 0.12.0__tar.gz → 0.12.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 (22) hide show
  1. {extract_python-0.12.0 → extract_python-0.12.2}/PKG-INFO +1 -1
  2. {extract_python-0.12.0 → extract_python-0.12.2}/extract_python/liteparse_.py +183 -110
  3. {extract_python-0.12.0 → extract_python-0.12.2}/extract_python/utils.py +46 -25
  4. {extract_python-0.12.0 → extract_python-0.12.2}/pyproject.toml +1 -1
  5. {extract_python-0.12.0 → extract_python-0.12.2}/.gitignore +0 -0
  6. {extract_python-0.12.0 → extract_python-0.12.2}/.python-version +0 -0
  7. {extract_python-0.12.0 → extract_python-0.12.2}/README.md +0 -0
  8. {extract_python-0.12.0 → extract_python-0.12.2}/benches/__init__.py +0 -0
  9. {extract_python-0.12.0 → extract_python-0.12.2}/benches/compare.ipynb +0 -0
  10. {extract_python-0.12.0 → extract_python-0.12.2}/benches/compare.py +0 -0
  11. {extract_python-0.12.0 → extract_python-0.12.2}/benches/constants.py +0 -0
  12. {extract_python-0.12.0 → extract_python-0.12.2}/data/.gitignore +0 -0
  13. {extract_python-0.12.0 → extract_python-0.12.2}/extract_python/__init__.py +0 -0
  14. {extract_python-0.12.0 → extract_python-0.12.2}/extract_python/__main__.py +0 -0
  15. {extract_python-0.12.0 → extract_python-0.12.2}/extract_python/cli/__init__.py +0 -0
  16. {extract_python-0.12.0 → extract_python-0.12.2}/extract_python/cli/doc.py +0 -0
  17. {extract_python-0.12.0 → extract_python-0.12.2}/extract_python/cli/utils.py +0 -0
  18. {extract_python-0.12.0 → extract_python-0.12.2}/extract_python/constants.py +0 -0
  19. {extract_python-0.12.0 → extract_python-0.12.2}/extract_python/docling_.py +0 -0
  20. {extract_python-0.12.0 → extract_python-0.12.2}/extract_python/marker_.py +0 -0
  21. {extract_python-0.12.0 → extract_python-0.12.2}/extract_python/mineru_.py +0 -0
  22. {extract_python-0.12.0 → extract_python-0.12.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: extract-python
3
- Version: 0.12.0
3
+ Version: 0.12.2
4
4
  Summary: Structured content extraction
5
5
  Project-URL: Homepage, https://github.com/ICIJ/extract-python
6
6
  Project-URL: Repository, https://github.com/ICIJ/extract-python
@@ -3,17 +3,21 @@ import logging
3
3
  import os
4
4
  from collections import defaultdict
5
5
  from collections.abc import AsyncIterable, Callable, Generator, Iterable, Sequence
6
+ from concurrent import futures
7
+ from concurrent.futures import ProcessPoolExecutor
6
8
  from contextlib import contextmanager
7
9
  from dataclasses import dataclass, field
8
10
  from functools import cache
9
11
  from itertools import batched
12
+ from multiprocessing import get_context
10
13
  from pathlib import Path
11
- from typing import TYPE_CHECKING, Literal, Self, cast
14
+ from typing import TYPE_CHECKING, Literal, Self
12
15
 
13
16
  from extract_core import (
14
17
  ConversionOutput,
15
18
  Error,
16
19
  InputDoc,
20
+ LiteParsePipelineConfig,
17
21
  OutputFormat,
18
22
  Pipeline,
19
23
  PipelineType,
@@ -22,6 +26,7 @@ from extract_core import (
22
26
  SupportedExt,
23
27
  )
24
28
  from pydantic import TypeAdapter
29
+ from tesserocr import tesserocr
25
30
 
26
31
  from .constants import ARTIFACTS, DEFAULT_MD_PAGE_SEP
27
32
  from .utils import (
@@ -29,6 +34,7 @@ from .utils import (
29
34
  Range,
30
35
  ResultBuffer,
31
36
  UsageTracker,
37
+ _run_with_concurrency,
32
38
  chunk_docs,
33
39
  path_to_artifacts_dirname,
34
40
  publish_and_consume,
@@ -37,6 +43,7 @@ from .utils import (
37
43
 
38
44
  logger = logging.getLogger(__name__)
39
45
  if TYPE_CHECKING:
46
+ import pypdfium2 as pdfium
40
47
  from liteparse import LiteParse
41
48
  from liteparse.types import (
42
49
  ExtractedImage,
@@ -45,8 +52,6 @@ if TYPE_CHECKING:
45
52
  ParsedPage,
46
53
  ParseResult,
47
54
  )
48
- from PIL import Image
49
- from tesserocr import PyTessBaseAPI
50
55
 
51
56
  _LITEPARSE_CONVERSION_ERRORS = tuple()
52
57
 
@@ -99,6 +104,9 @@ ResultAggregator = Callable[[InputDoc, list[LiteParsePageResult], Path], Result]
99
104
 
100
105
  @Pipeline.register(PipelineType.LITEPARSE)
101
106
  class LiteParsePipeline(Pipeline):
107
+ def __init__(self, config: LiteParsePipelineConfig):
108
+ super().__init__(config)
109
+
102
110
  async def extract_content(
103
111
  self, docs: Iterable[InputDoc], output_format: OutputFormat, output_path: Path
104
112
  ) -> AsyncIterable[Result]:
@@ -108,99 +116,69 @@ class LiteParsePipeline(Pipeline):
108
116
  if tessdata_path is None:
109
117
  msg = "couldn't find tessdata path in config nor TESSDATA_PREFIX env var"
110
118
  raise RuntimeError(msg)
111
- self._tessdata_path = str(tessdata_path)
119
+ self._tessdata_path = Path(tessdata_path)
112
120
  if self._config.detect_page_orientation:
113
121
  logger.info("reading docs page orientation detection...")
114
122
  batches = await self._batch_per_orientation(docs)
115
- logger.info("done with page orientation detection !")
116
123
  else:
117
124
  batches = chunk_docs(docs, self._config.page_batch_size)
118
125
  batches = ((None, (c,)) for c in batches)
126
+ logger.info("parsing pages...")
119
127
  async for res in self._parse(batches, output_format, output_path):
120
128
  yield res
121
129
 
122
130
  async def _batch_per_orientation(
123
131
  self, docs: Iterable[InputDoc]
124
132
  ) -> list[OrientedBatch]:
125
- result_queue = asyncio.Queue()
126
- publisher = self._batch_publisher(docs, result_queue)
127
- consumer = self._batch_consumer(result_queue)
128
- batches = [b async for b in publish_and_consume(publisher, consumer=consumer)]
133
+ logger.info("computing page complexities...")
134
+ complexities = await self._compute_complexities(docs)
135
+ batches = [b async for b in self._batch_with_orientation(complexities)]
129
136
  return batches
130
137
 
131
- async def _batch_publisher(
132
- self, docs: Iterable[InputDoc], queue: asyncio.Queue
133
- ) -> None:
134
- sem = asyncio.Semaphore(self._config.n_cpus - 1)
135
- logger.debug("creating page complexity parser...")
136
- parser = self._make_complexity_parser()
137
- logger.debug("creating page complexity parser...")
138
- parser.warm_up()
139
- with parser:
140
- async with asyncio.TaskGroup() as tg:
141
- for doc in docs:
142
- if doc.ext == SupportedExt.PDF:
143
- task = self._detect_complexity_and_queue(
144
- parser, doc, queue, sem
145
- )
146
- tg.create_task(task)
147
- else:
148
- queue.put_nowait((doc, None))
149
- queue.put_nowait(None)
150
-
151
- async def _batch_consumer(
152
- self, queue: asyncio.Queue
153
- ) -> AsyncIterable[OrientedBatch]:
154
- import pypdfium2 as pdfium # noqa: PLC0415
155
- import tesserocr # noqa: PLC0415
156
- from liteparse.types import PageComplexityStats # noqa: PLC0415
138
+ async def _compute_complexities(
139
+ self, docs: Iterable[InputDoc]
140
+ ) -> list[tuple[InputDoc, list["PageComplexityStats"]]]:
141
+ docs = list(docs)
142
+ batches = (pages for pages in chunk_docs(docs, self._config.page_batch_size))
143
+ aws = (_compute_batch_complexities(batch) for batch in batches)
144
+ batches = defaultdict(list)
145
+ async for pages, res in _run_with_concurrency(aws, self._config.n_cpus):
146
+ batches[pages.doc_idx].extend(res)
147
+ batches = [
148
+ (docs[doc_idx], sorted(comps, key=lambda c: c.page_number))
149
+ for doc_idx, comps in batches.items()
150
+ ]
151
+ return batches
157
152
 
153
+ async def _batch_with_orientation(
154
+ self, complexities: list[tuple[InputDoc, list["PageComplexityStats"]]]
155
+ ) -> AsyncIterable[OrientedBatch]:
158
156
  batch_size = self._config.page_batch_size
159
- osd_reader = tesserocr.PyTessBaseAPI(
160
- self._tessdata_path,
161
- lang="osd",
162
- oem=tesserocr.OEM.DEFAULT,
163
- psm=tesserocr.PSM.OSD_ONLY,
164
- )
165
- pages_per_orientation = defaultdict(list)
166
- doc_ix = -1
167
- with osd_reader:
168
- while (res := await queue.get()) is not None:
169
- doc_ix += 1
170
- doc, pdf_pages = res
171
- if pdf_pages is None:
172
- for batch in chunk_docs([doc], batch_size):
173
- rng = _liteparse_range(batch.page_range)
174
- pages_per_orientation[(rng, None)].append(batch)
175
- continue
176
- buffer = defaultdict(list)
177
- try:
178
- pdf_doc = None
179
- pages = cast(list[PageComplexityStats], pdf_pages)
180
- for page in pages:
181
- # We don't use orientation for regular page, we reserve osd for
182
- # pages were OCR is required
183
- if not _should_detect_orientation(page):
184
- buffer[None].append(page)
185
- continue
186
- if pdf_doc is None:
187
- pdf_doc = pdfium.PdfDocument(doc.path)
188
- page_im = (
189
- pdf_doc[page.page_number - 1].render(scale=3.0).to_pil()
190
- )
191
- orientation = _detect_orientation(osd_reader, page_im)
192
- buffer[orientation].append(page)
193
- finally:
194
- if pdf_doc is not None:
195
- pdf_doc.close()
196
- for orientation, pages in buffer.items():
157
+ batches_per_orientation = defaultdict(list)
158
+ not_pdfs = [doc for doc, comps in complexities if comps is None]
159
+ for doc in not_pdfs:
160
+ for batch in chunk_docs([doc], batch_size):
161
+ rng = _liteparse_range(batch.page_range)
162
+ batches_per_orientation[(rng, None)].append(batch)
163
+ complexities = [
164
+ (doc, comps) for doc, comps in complexities if comps is not None
165
+ ]
166
+ logger.info("reading complex pages orientation...")
167
+ offset = len(not_pdfs)
168
+ with self._executor() as executor:
169
+ for doc, comps in complexities:
170
+ pages_by_orientation = await _doc_pages_by_orientation(
171
+ doc, comps, executor, self._tessdata_path
172
+ )
173
+ for orientation, pages in pages_by_orientation.items():
197
174
  for batch in batched(pages, batch_size, strict=False):
198
- batch = _pages_from_complexities(doc, doc_ix, *batch) # noqa: PLW2901
175
+ batch = _pages_from_complexities(doc, offset, *batch) # noqa: PLW2901
199
176
  rng = _liteparse_range_from_pages(*batch)
200
- pages_per_orientation[(rng, orientation)].append(batch)
177
+ batches_per_orientation[(rng, orientation)].append(batch)
178
+ offset += 1
201
179
  # We yield pages per orientation only here to make sure to yield
202
180
  # (orientation, range) in the most regular way and avoid reload
203
- for (_, orientation), batches in pages_per_orientation.items():
181
+ for (_, orientation), batches in batches_per_orientation.items():
204
182
  for batch in batches:
205
183
  yield orientation, batch
206
184
 
@@ -220,6 +198,7 @@ class LiteParsePipeline(Pipeline):
220
198
  )
221
199
  raise NotImplementedError(fmt)
222
200
  result_queue = asyncio.Queue()
201
+ # Buffer partial conversion to filesystem as large docs are chunked into pieces
223
202
  with self._result_buffer() as buffer:
224
203
  publisher = self._parse_publisher(batches, liteparse_o_fmt, result_queue)
225
204
  consumer = self._parse_consumer(
@@ -235,14 +214,16 @@ class LiteParsePipeline(Pipeline):
235
214
  queue: asyncio.Queue,
236
215
  ) -> None:
237
216
  parser = None
238
- # Buffer partial conversion to filesystem as large docs are chunked into pieces
217
+ parsers = []
218
+ close_parsers_tasks = []
239
219
  # Prevent more than n_cpu concurrent processing
240
- sem = asyncio.Semaphore(self._config.n_cpus - 1)
220
+ sem = asyncio.Semaphore(max(1, self._config.n_cpus - 1))
241
221
  # Results may return in arbitrary order, we need to wait until parser is not
242
222
  # used any longer before
243
223
  tracker = UsageTracker()
244
224
  current_key = None
245
- with _close_parser(parser):
225
+ publish_tasks = []
226
+ with _close_parsers(parsers):
246
227
  async with asyncio.TaskGroup() as tg:
247
228
  for orientation, batch in batches:
248
229
  # We avoid recreating the parser at each iteration since it creates
@@ -253,16 +234,19 @@ class LiteParsePipeline(Pipeline):
253
234
  key = (page_range, orientation)
254
235
  if key != current_key:
255
236
  if parser is not None:
256
- await tracker.wait_until_free()
257
- parser.__exit__()
237
+ close_t = tg.create_task(_await_and_close(tracker, parser))
238
+ close_parsers_tasks.append(close_t)
258
239
  logger.debug("loading parser for %s", key)
259
240
  parser = self._make_parser(output_format, key, n_pages=n_pages)
241
+ parsers.append(parser)
260
242
  parser.__enter__().warm_up()
261
243
  current_key = key
262
- await sem.acquire(), await tracker.acquire()
244
+ await sem.acquire(), await tracker.acquire(parser)
263
245
  task = self._convert_and_queue(parser, batch, queue, sem, tracker)
264
- tg.create_task(task)
265
- queue.put_nowait(None)
246
+ publish_tasks.append(tg.create_task(task))
247
+ await asyncio.gather(*publish_tasks)
248
+ queue.put_nowait(None)
249
+ await asyncio.gather(*close_parsers_tasks)
266
250
 
267
251
  async def _parse_consumer(
268
252
  self,
@@ -281,18 +265,6 @@ class LiteParsePipeline(Pipeline):
281
265
  if buffer.is_complete(doc_ix):
282
266
  res = result_aggregator(doc, buffer.pop_complete(doc_ix), output_path)
283
267
  yield res
284
- logger.info("done consuming !")
285
-
286
- async def _detect_complexity_and_queue(
287
- self,
288
- parser: "LiteParse",
289
- doc: InputDoc,
290
- queue: asyncio.Queue,
291
- sem: asyncio.Semaphore,
292
- ) -> None:
293
- pages = await asyncio.to_thread(parser.is_complex, doc.path)
294
- sem.release()
295
- queue.put_nowait((doc, pages))
296
268
 
297
269
  async def _convert_and_queue(
298
270
  self,
@@ -302,8 +274,8 @@ class LiteParsePipeline(Pipeline):
302
274
  sem: asyncio.Semaphore,
303
275
  tracker: UsageTracker,
304
276
  ) -> None:
305
- res = await asyncio.to_thread(parser.parse, pages[0].doc.path)
306
- sem.release(), await tracker.release()
277
+ res = await parser.aparse(pages[0].doc.path)
278
+ sem.release(), await tracker.release(parser)
307
279
  res = LiteParsePageResult(
308
280
  pages=res.pages, errors=res.page_errors, images=res.images
309
281
  )
@@ -324,7 +296,7 @@ class LiteParsePipeline(Pipeline):
324
296
  target_pages, orientation = key
325
297
  if orientation is not None:
326
298
  orientation = [(p + 1, orientation) for p in range(n_pages)]
327
- pool_size = self._config.n_cpus - 1
299
+ pool_size = max(1, self._config.n_cpus - 1)
328
300
  return LiteParse(
329
301
  ocr_enabled=True,
330
302
  extract_links=True,
@@ -348,10 +320,13 @@ class LiteParsePipeline(Pipeline):
348
320
  **self._config.config.model_dump(),
349
321
  )
350
322
 
351
- def _make_complexity_parser(self) -> "LiteParse":
352
- from liteparse.parser import LiteParse # noqa: PLC0415
353
-
354
- return LiteParse(ocr_enabled=False, pool_size=self._config.n_cpus - 1)
323
+ @contextmanager
324
+ def _executor(self) -> Generator[ProcessPoolExecutor]:
325
+ executor = ProcessPoolExecutor(
326
+ max_workers=self._config.n_cpus, mp_context=get_context("spawn")
327
+ )
328
+ with executor:
329
+ yield executor
355
330
 
356
331
  def _result_buffer(self) -> ResultBuffer:
357
332
 
@@ -364,6 +339,18 @@ class LiteParsePipeline(Pipeline):
364
339
  return buffer
365
340
 
366
341
 
342
+ async def _compute_batch_complexities(
343
+ pages: ProcessedPages,
344
+ ) -> tuple[ProcessedPages, list["PageComplexityStats"]]:
345
+ from liteparse.parser import LiteParse # noqa: PLC0415
346
+
347
+ target_pages = _liteparse_range_from_pages(pages)
348
+ parser = LiteParse(ocr_enabled=False, target_pages=target_pages)
349
+ with parser:
350
+ comps = await asyncio.to_thread(parser.is_complex, pages.doc.path)
351
+ return pages, comps
352
+
353
+
367
354
  def aggregate_pages_md(
368
355
  doc: InputDoc,
369
356
  results: Sequence[LiteParsePageResult],
@@ -427,12 +414,12 @@ def _load_conversion_result(path: Path) -> "ParseResult":
427
414
 
428
415
 
429
416
  @contextmanager
430
- def _close_parser(parser: "LiteParse | None") -> Generator[None]:
417
+ def _close_parsers(parsers: Iterable["LiteParse"]) -> Generator[None]:
431
418
  try:
432
419
  yield
433
420
  finally:
434
- if parser is not None:
435
- parser.close()
421
+ for p in parsers:
422
+ p.close()
436
423
 
437
424
 
438
425
  def _liteparse_range(rng: Range) -> str:
@@ -483,11 +470,97 @@ def _should_detect_orientation(page: "PageComplexityStats") -> bool:
483
470
  return False
484
471
 
485
472
 
486
- def _detect_orientation(osd_reader: "PyTessBaseAPI", page_im: "Image.Image") -> int:
487
- osd_reader.SetImage(page_im)
488
- orientation = osd_reader.DetectOrientationScript()["orient_deg"]
489
- return _clipped_orientation(orientation)
473
+ async def _doc_pages_by_orientation(
474
+ doc: InputDoc,
475
+ complexities: list["PageComplexityStats"],
476
+ executor: ProcessPoolExecutor,
477
+ tessdata_path: Path,
478
+ ) -> dict[int, list["PageComplexityStats"]]:
479
+ pages_by_orientation = defaultdict(list)
480
+ complexities = [(p, _should_detect_orientation(p)) for p in complexities]
481
+ for p, should_detect in complexities:
482
+ if not should_detect:
483
+ pages_by_orientation[None].append(p)
484
+ complexities = [p for p, should_detect in complexities if should_detect]
485
+ if not complexities:
486
+ return pages_by_orientation
487
+ futs = []
488
+ for c in complexities:
489
+ fut = executor.submit(_detect_page_orientation, doc.path, c, tessdata_path)
490
+ futs.append(fut)
491
+ for fut in futures.as_completed(futs):
492
+ orientation, p = fut.result()
493
+ pages_by_orientation[orientation].append(p)
494
+ return pages_by_orientation
495
+
496
+
497
+ async def _detect_complexity(
498
+ parser: "LiteParse", doc: InputDoc
499
+ ) -> tuple[InputDoc, list["PageComplexityStats"] | None]:
500
+ if doc.ext != SupportedExt.PDF:
501
+ return doc, None
502
+ complexities = await asyncio.to_thread(parser.is_complex, doc.path)
503
+ return doc, complexities
504
+
505
+
506
+ def _detect_page_orientation(
507
+ doc: Path, complexity: "PageComplexityStats", tessdata_path: Path
508
+ ) -> tuple[int, "PageComplexityStats"]:
509
+ pdf_doc = _PDF_DOC_CACHE.get(doc)
510
+ page = pdf_doc.get_page(complexity.page_number - 1)
511
+ page = page.render(scale=3)
512
+ osd_reader = _get_osd_reader(tessdata_path)
513
+ osd_reader.SetImage(page.to_pil())
514
+ orientation = osd_reader.DetectOrientationScript()
515
+ return _clipped_orientation(orientation["orient_deg"]), complexity
490
516
 
491
517
 
492
518
  def _clipped_orientation(angle: float) -> int:
493
519
  return min((abs(angle - o) % 360, o) for o in _CLIPPED_ORIENTATIONS)[1]
520
+
521
+
522
+ async def _await_and_close(tracker: UsageTracker, parser: "LiteParse") -> None:
523
+ await tracker.wait_until_free(parser)
524
+ parser.close()
525
+
526
+
527
+ def _get_osd_reader(tessdata_path: Path) -> "tesserocr.PyTessBaseAPI":
528
+ import tesserocr # noqa: PLC0415
529
+
530
+ global _TESSERACT
531
+ if _TESSERACT is None:
532
+ osd_reader = tesserocr.PyTessBaseAPI(
533
+ str(tessdata_path),
534
+ lang="osd",
535
+ oem=tesserocr.OEM.DEFAULT,
536
+ psm=tesserocr.PSM.OSD_ONLY,
537
+ )
538
+ _TESSERACT = osd_reader
539
+ return _TESSERACT
540
+
541
+
542
+ class _PDFDocCache:
543
+ def __init__(self):
544
+ self._path: Path | None = None
545
+ self._doc: pdfium.PdfDocument | None = None
546
+
547
+ def get(self, path: Path) -> "pdfium.PdfDocument":
548
+ import pypdfium2 as pdfium # noqa: PLC0415
549
+
550
+ if path != self._path:
551
+ if self._doc is not None:
552
+ self._doc.close()
553
+ self._doc = pdfium.PdfDocument(path)
554
+ self._path = path
555
+ return self._doc
556
+
557
+ def __del__(self):
558
+ if self._doc is not None:
559
+ self._doc.close()
560
+
561
+
562
+ # we have to use a global cache because pdfnium is not thread safe and not
563
+ # pickleable
564
+ _PDF_DOC_CACHE = _PDFDocCache()
565
+ # tesseract is also not pickleable
566
+ _TESSERACT: "tesserocr.PyTessBaseAPI | None" = None
@@ -6,14 +6,16 @@ import shutil
6
6
  import uuid
7
7
  from collections import defaultdict, deque
8
8
  from collections.abc import (
9
+ AsyncGenerator,
9
10
  AsyncIterable,
11
+ Awaitable,
10
12
  Callable,
11
13
  Coroutine,
12
14
  Generator,
13
15
  Iterable,
14
16
  Iterator,
15
17
  )
16
- from contextlib import contextmanager
18
+ from contextlib import asynccontextmanager, contextmanager
17
19
  from copy import copy
18
20
  from dataclasses import dataclass
19
21
  from functools import wraps
@@ -138,35 +140,38 @@ def chunk_docs(
138
140
 
139
141
  class UsageTracker:
140
142
  def __init__(self):
141
- self._usages = 0
143
+ self._usages = defaultdict(int)
142
144
  self._lock = asyncio.Lock()
143
- self._is_free = asyncio.Event()
144
-
145
- async def __aenter__(self) -> Self:
146
- await self.acquire()
147
- return self
148
-
149
- async def __aexit__(
150
- self,
151
- exc_type: type[BaseException] | None,
152
- exc_val: BaseException | None,
153
- exc_tb: TracebackType | None,
154
- ):
155
- await self.release()
156
-
157
- async def acquire(self) -> None:
145
+ self._is_free = dict()
146
+
147
+ @asynccontextmanager
148
+ async def track(self, obj: Any) -> AsyncGenerator[None]:
149
+ await self.acquire(obj)
150
+ try:
151
+ yield
152
+ finally:
153
+ await self.release(obj)
154
+
155
+ async def acquire(self, obj: Any) -> None:
156
+ key = _obj_key(obj)
158
157
  async with self._lock:
159
- self._is_free.clear()
160
- self._usages += 1
158
+ if key not in self._is_free:
159
+ self._is_free[key] = asyncio.Event()
160
+ self._usages[key] += 1
161
161
 
162
- async def release(self) -> None:
162
+ async def release(self, obj: Any) -> None:
163
+ key = _obj_key(obj)
163
164
  async with self._lock:
164
- self._usages -= 1
165
- if self._usages == 0:
166
- self._is_free.set()
165
+ self._usages[key] -= 1
166
+ if self._usages[key] == 0:
167
+ self._is_free[key].set()
168
+
169
+ async def wait_until_free(self, obj: Any) -> None:
170
+ await self._is_free[_obj_key(obj)].wait()
167
171
 
168
- async def wait_until_free(self) -> None:
169
- await self._is_free.wait()
172
+
173
+ def _obj_key(obj: Any) -> int:
174
+ return id(obj)
170
175
 
171
176
 
172
177
  class ResultBuffer[R]:
@@ -387,3 +392,19 @@ async def publish_and_consume[R](
387
392
  consume_t = asyncio.create_task(anext(consumer, sentinel))
388
393
  awaited.append(consume_t)
389
394
  logger.debug("done with publish and consume !")
395
+
396
+
397
+ async def _run_with_concurrency(
398
+ aws: Iterable[Coroutine | asyncio.Future], max_concurrency: int
399
+ ) -> AsyncGenerator:
400
+ max_concurrency = asyncio.Semaphore(max_concurrency)
401
+ aws = (_run_with_semaphore(aw, max_concurrency) for aw in aws)
402
+ async for res in asyncio.as_completed(aws):
403
+ yield await res
404
+
405
+
406
+ async def _run_with_semaphore[T](
407
+ aws: Awaitable[T] | asyncio.Future, sem: asyncio.Semaphore
408
+ ) -> T:
409
+ async with sem:
410
+ return await aws
@@ -118,7 +118,7 @@ log_cli_level = "INFO"
118
118
  log_cli_format = "[%(levelname)s][%(asctime)s.%(msecs)03d][%(name)s]: %(message)s"
119
119
  markers = [
120
120
  "integration: integration test",
121
- "miner_u: minerU test which require conflicting extras",
121
+ "mineru: minerU test which require conflicting extras",
122
122
  ]
123
123
 
124
124
  [build-system]
File without changes