fluentmap 0.0.1__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.
- fluentmap-0.0.1/LICENSE +21 -0
- fluentmap-0.0.1/PKG-INFO +13 -0
- fluentmap-0.0.1/README.md +4 -0
- fluentmap-0.0.1/fluentmap.py +766 -0
- fluentmap-0.0.1/pyproject.toml +79 -0
fluentmap-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 leavers
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
fluentmap-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: fluentmap
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Drop-in replacement for Python map with concurrency support
|
|
5
|
+
Author-Email: Leavers <leavers930@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# Fluentmap
|
|
11
|
+
|
|
12
|
+
Drop-in replacement for Python map with concurrency support.
|
|
13
|
+
|
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fluentmap is a drop-in replacement for Python map with concurrency support.
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2020-2024, Leavers.
|
|
5
|
+
License: MIT
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from queue import Empty, Queue
|
|
9
|
+
from threading import Event, Thread
|
|
10
|
+
from typing import (
|
|
11
|
+
Any,
|
|
12
|
+
Callable,
|
|
13
|
+
Dict,
|
|
14
|
+
Iterable,
|
|
15
|
+
Iterator,
|
|
16
|
+
List,
|
|
17
|
+
Optional,
|
|
18
|
+
Protocol,
|
|
19
|
+
Set,
|
|
20
|
+
Tuple,
|
|
21
|
+
TypeVar,
|
|
22
|
+
Union,
|
|
23
|
+
cast,
|
|
24
|
+
overload,
|
|
25
|
+
runtime_checkable,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__ = "0.0.1"
|
|
29
|
+
__all__ = ("Arguments", "map")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
34
|
+
R = TypeVar("R")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class FutureLike(Protocol[T_co]):
|
|
39
|
+
def done(self) -> bool: ...
|
|
40
|
+
|
|
41
|
+
def result(self, timeout: Any = None) -> T_co: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@runtime_checkable
|
|
45
|
+
class SupportsSubmit(Protocol):
|
|
46
|
+
def submit(*args: Any, **kwargs: Any) -> FutureLike: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Arguments:
|
|
50
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
51
|
+
self.args = args
|
|
52
|
+
self.kwargs = kwargs
|
|
53
|
+
|
|
54
|
+
def __eq__(self, obj: object) -> bool:
|
|
55
|
+
if not isinstance(obj, Arguments):
|
|
56
|
+
return False
|
|
57
|
+
return self.args == obj.args and self.kwargs == obj.kwargs
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"{self.__class__.__name__}(args={self.args}, kwargs={self.kwargs})"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _simple_map(
|
|
64
|
+
fn: Callable[..., T],
|
|
65
|
+
args: Iterable[Any],
|
|
66
|
+
*more_args: Iterable[Any],
|
|
67
|
+
batch_size: int = 0,
|
|
68
|
+
num_prepare: int = 0,
|
|
69
|
+
on_return: Optional[Callable[[T], R]] = None,
|
|
70
|
+
) -> Iterator[Union[T, R]]:
|
|
71
|
+
is_batch_args = True if batch_size > 1 else False
|
|
72
|
+
|
|
73
|
+
if more_args:
|
|
74
|
+
iterable_args = cast(Iterable[Any], zip(args, *more_args))
|
|
75
|
+
extract_args = True
|
|
76
|
+
else:
|
|
77
|
+
iterable_args = args
|
|
78
|
+
extract_args = False
|
|
79
|
+
|
|
80
|
+
def submit(fn_args: Union[Arguments, Iterable[Any], Any]) -> Union[T, R]:
|
|
81
|
+
if is_batch_args:
|
|
82
|
+
res = fn(fn_args)
|
|
83
|
+
else:
|
|
84
|
+
if isinstance(fn_args, Arguments):
|
|
85
|
+
res = fn(*fn_args.args, **fn_args.kwargs)
|
|
86
|
+
elif isinstance(fn_args, Iterable) and extract_args:
|
|
87
|
+
res = fn(*fn_args)
|
|
88
|
+
else:
|
|
89
|
+
res = fn(fn_args)
|
|
90
|
+
if on_return is None:
|
|
91
|
+
return res
|
|
92
|
+
return on_return(res)
|
|
93
|
+
|
|
94
|
+
if num_prepare == 0:
|
|
95
|
+
batch: List[Any] = []
|
|
96
|
+
for args in iterable_args:
|
|
97
|
+
if is_batch_args:
|
|
98
|
+
batch.append(args)
|
|
99
|
+
if len(batch) < batch_size:
|
|
100
|
+
continue
|
|
101
|
+
yield submit(batch)
|
|
102
|
+
batch = []
|
|
103
|
+
else:
|
|
104
|
+
yield submit(args)
|
|
105
|
+
if batch:
|
|
106
|
+
yield submit(batch)
|
|
107
|
+
else:
|
|
108
|
+
undefined = object()
|
|
109
|
+
queue: Queue = Queue()
|
|
110
|
+
exc_event = Event()
|
|
111
|
+
|
|
112
|
+
def worker():
|
|
113
|
+
batch: List[Any] = []
|
|
114
|
+
for args in iterable_args:
|
|
115
|
+
if exc_event.is_set():
|
|
116
|
+
break
|
|
117
|
+
if is_batch_args:
|
|
118
|
+
batch.append(args)
|
|
119
|
+
if len(batch) < batch_size:
|
|
120
|
+
continue
|
|
121
|
+
queue.put(batch)
|
|
122
|
+
batch = []
|
|
123
|
+
else:
|
|
124
|
+
queue.put(args)
|
|
125
|
+
del args
|
|
126
|
+
while queue.qsize() >= num_prepare:
|
|
127
|
+
if exc_event.is_set():
|
|
128
|
+
break
|
|
129
|
+
exc_event.wait(0.05)
|
|
130
|
+
if not exc_event.is_set() and batch:
|
|
131
|
+
queue.put(batch)
|
|
132
|
+
queue.put(undefined)
|
|
133
|
+
|
|
134
|
+
worker_thread = Thread(target=worker, daemon=True)
|
|
135
|
+
worker_thread.start()
|
|
136
|
+
while True:
|
|
137
|
+
try:
|
|
138
|
+
data = queue.get(timeout=0.05)
|
|
139
|
+
except Empty:
|
|
140
|
+
continue
|
|
141
|
+
if data is undefined:
|
|
142
|
+
break
|
|
143
|
+
try:
|
|
144
|
+
yield submit(data)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
exc_event.set()
|
|
147
|
+
worker_thread.join()
|
|
148
|
+
raise exc
|
|
149
|
+
finally:
|
|
150
|
+
del data
|
|
151
|
+
worker_thread.join()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _process_chunk(
|
|
155
|
+
fn: Callable,
|
|
156
|
+
fn_args_chunk: List[Union[Arguments, Iterable[Any], Any]],
|
|
157
|
+
*,
|
|
158
|
+
extract_args: bool,
|
|
159
|
+
is_batch_args: bool,
|
|
160
|
+
) -> List[Tuple[Any, bool]]:
|
|
161
|
+
result: List[Tuple[Any, bool]] = []
|
|
162
|
+
if is_batch_args:
|
|
163
|
+
for fn_args in fn_args_chunk:
|
|
164
|
+
try:
|
|
165
|
+
ret = fn(fn_args)
|
|
166
|
+
except BaseException as exc:
|
|
167
|
+
result.append((exc, True))
|
|
168
|
+
else:
|
|
169
|
+
result.append((ret, False))
|
|
170
|
+
else:
|
|
171
|
+
for fn_args in fn_args_chunk:
|
|
172
|
+
try:
|
|
173
|
+
if isinstance(fn_args, Arguments):
|
|
174
|
+
ret = fn(*fn_args.args, **fn_args.kwargs)
|
|
175
|
+
elif isinstance(fn_args, Iterable) and extract_args:
|
|
176
|
+
ret = fn(*fn_args)
|
|
177
|
+
else:
|
|
178
|
+
ret = fn(fn_args)
|
|
179
|
+
except BaseException as exc:
|
|
180
|
+
result.append((exc, True))
|
|
181
|
+
else:
|
|
182
|
+
result.append((ret, False))
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _concurrent_chunk_map(
|
|
187
|
+
fn: Callable[..., T],
|
|
188
|
+
args: Iterable[Any],
|
|
189
|
+
*more_args: Iterable[Any],
|
|
190
|
+
executor: SupportsSubmit,
|
|
191
|
+
chunk_size: int,
|
|
192
|
+
batch_size: int = 0,
|
|
193
|
+
num_prepare: int = 0,
|
|
194
|
+
sort_by_completion: bool = False,
|
|
195
|
+
on_return: Optional[Callable[[T], R]] = None,
|
|
196
|
+
) -> Iterator[Union[T, R]]:
|
|
197
|
+
assert chunk_size > 1
|
|
198
|
+
is_batch_args = True if batch_size > 1 else False
|
|
199
|
+
|
|
200
|
+
args_iter: Iterator[Any]
|
|
201
|
+
if more_args:
|
|
202
|
+
args_iter = iter(zip(args, *more_args))
|
|
203
|
+
|
|
204
|
+
# Whether to deconstruct iterable args in submit, this helps sumbit identify
|
|
205
|
+
# whether an iterable fn_args is a batch generated by _concurrent_map itself
|
|
206
|
+
# (which should be deconstructed) or a item in args given by users (which
|
|
207
|
+
# should not be deconstructed)
|
|
208
|
+
extract_args = True
|
|
209
|
+
else:
|
|
210
|
+
args_iter = iter(args)
|
|
211
|
+
extract_args = False
|
|
212
|
+
|
|
213
|
+
# Indicates the chunk currently being assembled
|
|
214
|
+
chunk: List[Any] = []
|
|
215
|
+
|
|
216
|
+
# The chunk sequence number which is auto-incremental and unique.
|
|
217
|
+
chunk_cursor = 0
|
|
218
|
+
|
|
219
|
+
# Indicates the batch currently being assembled. If batch_size is 0, the length
|
|
220
|
+
# of batch will only be 1 and will be deconstructed before being added to
|
|
221
|
+
# pending_batches.
|
|
222
|
+
batch: List[Any] = []
|
|
223
|
+
|
|
224
|
+
# Batch cursors which have been submitted but not yielded yet.
|
|
225
|
+
task_indices: Set[int] = set()
|
|
226
|
+
|
|
227
|
+
# Futures which have not been yielded.
|
|
228
|
+
futures: Dict[int, FutureLike[List[Tuple[Union[T, BaseException], bool]]]] = {}
|
|
229
|
+
|
|
230
|
+
# Store results when sort_by_completion is False to make sure results are returned
|
|
231
|
+
# in the order of submission.
|
|
232
|
+
result_cache: Dict[int, List[Tuple[Union[T, BaseException], bool]]] = {}
|
|
233
|
+
|
|
234
|
+
# Used to reorder results and communicate with handle_result.
|
|
235
|
+
result_queue: Queue = Queue()
|
|
236
|
+
|
|
237
|
+
# Indicates the maximum index of result that has been processed by handle_result
|
|
238
|
+
# when sort_by_completion is False
|
|
239
|
+
result_cursor = 0
|
|
240
|
+
|
|
241
|
+
# Indicates the maximum index of result that has been returned when
|
|
242
|
+
# sort_by_completion is False
|
|
243
|
+
result_cursor_bound = 0
|
|
244
|
+
|
|
245
|
+
def submit(
|
|
246
|
+
fn_args_chunk: List[Union[Arguments, Iterable[Any], Any]],
|
|
247
|
+
) -> FutureLike[List[Tuple[Union[T, BaseException], bool]]]:
|
|
248
|
+
return executor.submit(
|
|
249
|
+
_process_chunk,
|
|
250
|
+
fn,
|
|
251
|
+
fn_args_chunk,
|
|
252
|
+
extract_args=extract_args,
|
|
253
|
+
is_batch_args=is_batch_args,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if on_return is None:
|
|
257
|
+
|
|
258
|
+
def handle_result_chunk(
|
|
259
|
+
i: int,
|
|
260
|
+
result_chunk: List[Tuple[Union[T, BaseException], bool]],
|
|
261
|
+
) -> None:
|
|
262
|
+
nonlocal result_cursor, result_cursor_bound
|
|
263
|
+
|
|
264
|
+
if sort_by_completion:
|
|
265
|
+
# yield result in the order of completion
|
|
266
|
+
result_queue.put((i, result_chunk))
|
|
267
|
+
else:
|
|
268
|
+
# yield result in the order of submission
|
|
269
|
+
result_cursor_bound = max(result_cursor_bound, i)
|
|
270
|
+
result_cache[i] = result_chunk
|
|
271
|
+
while result_cursor <= result_cursor_bound:
|
|
272
|
+
if result_cursor not in result_cache:
|
|
273
|
+
return
|
|
274
|
+
result_chunk = result_cache.pop(result_cursor)
|
|
275
|
+
result_queue.put((result_cursor, result_chunk))
|
|
276
|
+
result_cursor += 1
|
|
277
|
+
|
|
278
|
+
def stop_result_handler() -> None: ...
|
|
279
|
+
|
|
280
|
+
else:
|
|
281
|
+
|
|
282
|
+
def handle_result_concurrently(inqueue: Queue, outqueue: Queue) -> None:
|
|
283
|
+
while True:
|
|
284
|
+
try:
|
|
285
|
+
pair: Optional[
|
|
286
|
+
Tuple[int, List[Tuple[Union[T, BaseException], bool]]]
|
|
287
|
+
] = inqueue.get(timeout=0.01)
|
|
288
|
+
except Empty:
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
if pair is None:
|
|
292
|
+
break
|
|
293
|
+
|
|
294
|
+
i, raw_result_chunk = pair
|
|
295
|
+
results: List[Tuple[Union[T, BaseException], bool]] = []
|
|
296
|
+
for raw_result, is_exc in raw_result_chunk:
|
|
297
|
+
if is_exc:
|
|
298
|
+
results.append((raw_result, True))
|
|
299
|
+
continue
|
|
300
|
+
try:
|
|
301
|
+
new_result = on_return(raw_result) # type: ignore[arg-type]
|
|
302
|
+
results.append((new_result, False)) # type: ignore[arg-type]
|
|
303
|
+
except BaseException as exc:
|
|
304
|
+
results.append((exc, True))
|
|
305
|
+
del exc
|
|
306
|
+
finally:
|
|
307
|
+
del raw_result
|
|
308
|
+
outqueue.put((i, results))
|
|
309
|
+
del pair
|
|
310
|
+
|
|
311
|
+
raw_result_queue: Queue = Queue()
|
|
312
|
+
result_handler: Optional[Thread] = None
|
|
313
|
+
|
|
314
|
+
def stop_result_handler() -> None:
|
|
315
|
+
nonlocal result_handler
|
|
316
|
+
if result_handler is None or not result_handler.is_alive():
|
|
317
|
+
return
|
|
318
|
+
raw_result_queue.put(None)
|
|
319
|
+
result_handler.join()
|
|
320
|
+
result_handler = None
|
|
321
|
+
|
|
322
|
+
def handle_result_chunk(
|
|
323
|
+
i: int,
|
|
324
|
+
result_chunk: List[Tuple[Union[T, BaseException], bool]],
|
|
325
|
+
) -> None:
|
|
326
|
+
nonlocal result_handler, result_cache
|
|
327
|
+
nonlocal result_cursor, result_cursor_bound
|
|
328
|
+
|
|
329
|
+
if result_handler is None:
|
|
330
|
+
result_handler = Thread(
|
|
331
|
+
target=handle_result_concurrently,
|
|
332
|
+
args=(raw_result_queue, result_queue),
|
|
333
|
+
daemon=True,
|
|
334
|
+
)
|
|
335
|
+
result_handler.start()
|
|
336
|
+
|
|
337
|
+
if sort_by_completion:
|
|
338
|
+
# yield result in the order of completion
|
|
339
|
+
raw_result_queue.put((i, result_chunk))
|
|
340
|
+
else:
|
|
341
|
+
# yield result in the order of submission
|
|
342
|
+
result_cursor_bound = max(result_cursor_bound, i)
|
|
343
|
+
result_cache[i] = result_chunk
|
|
344
|
+
while result_cursor <= result_cursor_bound:
|
|
345
|
+
if result_cursor not in result_cache:
|
|
346
|
+
return
|
|
347
|
+
result = result_cache.pop(result_cursor)
|
|
348
|
+
raw_result_queue.put((result_cursor, result))
|
|
349
|
+
result_cursor += 1
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
# Indicates whether there are pending data in args_iter. At least one
|
|
353
|
+
# iteration should be invoked so the initial value is True and it will be set
|
|
354
|
+
# as False on StopIteration.
|
|
355
|
+
has_args = True
|
|
356
|
+
|
|
357
|
+
# Conditions to stop iteration (must meet all requirements):
|
|
358
|
+
# 1. No more data in args_iter (has_args is False)
|
|
359
|
+
# 2. The last batch has been submitted (batch is empty)
|
|
360
|
+
# 3. All results have been yielded (task_indices is empty)
|
|
361
|
+
while has_args or chunk or batch or task_indices:
|
|
362
|
+
if has_args and (num_prepare == 0 or len(futures) < num_prepare):
|
|
363
|
+
try:
|
|
364
|
+
fn_args = next(args_iter)
|
|
365
|
+
if is_batch_args:
|
|
366
|
+
batch.append(fn_args)
|
|
367
|
+
if len(batch) >= batch_size:
|
|
368
|
+
chunk.append(batch)
|
|
369
|
+
batch = []
|
|
370
|
+
if len(chunk) >= chunk_size:
|
|
371
|
+
futures[chunk_cursor] = submit(chunk)
|
|
372
|
+
task_indices.add(chunk_cursor)
|
|
373
|
+
chunk_cursor += 1
|
|
374
|
+
chunk = []
|
|
375
|
+
else:
|
|
376
|
+
chunk.append(fn_args)
|
|
377
|
+
if len(chunk) >= chunk_size:
|
|
378
|
+
futures[chunk_cursor] = submit(chunk)
|
|
379
|
+
task_indices.add(chunk_cursor)
|
|
380
|
+
chunk_cursor += 1
|
|
381
|
+
chunk = []
|
|
382
|
+
except StopIteration:
|
|
383
|
+
if is_batch_args and batch:
|
|
384
|
+
chunk.append(batch)
|
|
385
|
+
batch = []
|
|
386
|
+
if chunk:
|
|
387
|
+
futures[chunk_cursor] = submit(chunk)
|
|
388
|
+
task_indices.add(chunk_cursor)
|
|
389
|
+
chunk_cursor += 1
|
|
390
|
+
chunk = []
|
|
391
|
+
has_args = False
|
|
392
|
+
|
|
393
|
+
if futures:
|
|
394
|
+
finished_indices: List[int] = []
|
|
395
|
+
for i, future in futures.items():
|
|
396
|
+
if not future.done():
|
|
397
|
+
continue
|
|
398
|
+
finished_indices.append(i)
|
|
399
|
+
handle_result_chunk(i, future.result())
|
|
400
|
+
for i in finished_indices:
|
|
401
|
+
futures.pop(i)
|
|
402
|
+
|
|
403
|
+
while not result_queue.empty():
|
|
404
|
+
i, results = result_queue.get()
|
|
405
|
+
task_indices.remove(i)
|
|
406
|
+
for result, is_exc in results:
|
|
407
|
+
if is_exc:
|
|
408
|
+
stop_result_handler()
|
|
409
|
+
raise cast(BaseException, result)
|
|
410
|
+
else:
|
|
411
|
+
yield result
|
|
412
|
+
finally:
|
|
413
|
+
stop_result_handler()
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _concurrent_map(
|
|
417
|
+
fn: Callable[..., T],
|
|
418
|
+
args: Iterable[Any],
|
|
419
|
+
*more_args: Iterable[Any],
|
|
420
|
+
executor: SupportsSubmit,
|
|
421
|
+
batch_size: int = 0,
|
|
422
|
+
num_prepare: int = 0,
|
|
423
|
+
sort_by_completion: bool = False,
|
|
424
|
+
on_return: Optional[Callable[[T], R]] = None,
|
|
425
|
+
) -> Iterator[Union[T, R]]:
|
|
426
|
+
is_batch_args = True if batch_size > 1 else False
|
|
427
|
+
|
|
428
|
+
args_iter: Iterator[Any]
|
|
429
|
+
if more_args:
|
|
430
|
+
args_iter = iter(zip(args, *more_args))
|
|
431
|
+
|
|
432
|
+
# Whether to deconstruct iterable args in submit, this helps sumbit identify
|
|
433
|
+
# whether an iterable fn_args is a batch generated by _concurrent_map itself
|
|
434
|
+
# (which should be deconstructed) or a item in args given by users (which
|
|
435
|
+
# should not be deconstructed)
|
|
436
|
+
extract_args = True
|
|
437
|
+
else:
|
|
438
|
+
args_iter = iter(args)
|
|
439
|
+
extract_args = False
|
|
440
|
+
|
|
441
|
+
# Indicates the batch currently being assembled. If batch_size is 0, the length
|
|
442
|
+
# of batch will only be 1 and will be deconstructed before being added to
|
|
443
|
+
# pending_batches.
|
|
444
|
+
batch: List[Any] = []
|
|
445
|
+
|
|
446
|
+
# The batch sequence number which is auto-incremental and unique.
|
|
447
|
+
batch_cursor = 0
|
|
448
|
+
|
|
449
|
+
# Batch cursors which have been submitted but not yielded yet.
|
|
450
|
+
task_indices: Set[int] = set()
|
|
451
|
+
|
|
452
|
+
# Futures which have not been yielded.
|
|
453
|
+
futures: Dict[int, FutureLike[T]] = {}
|
|
454
|
+
|
|
455
|
+
# Store results when sort_by_completion is False to make sure results are returned
|
|
456
|
+
# in the order of submission.
|
|
457
|
+
result_cache: Dict[int, T] = {}
|
|
458
|
+
|
|
459
|
+
# Used to reorder results and communicate with handle_result.
|
|
460
|
+
result_queue: Queue = Queue()
|
|
461
|
+
|
|
462
|
+
# Indicates the maximum index of result that has been processed by handle_result
|
|
463
|
+
# when sort_by_completion is False
|
|
464
|
+
result_cursor = 0
|
|
465
|
+
|
|
466
|
+
# Indicates the maximum index of result that has been returned when
|
|
467
|
+
# sort_by_completion is False
|
|
468
|
+
result_cursor_bound = 0
|
|
469
|
+
|
|
470
|
+
def submit(
|
|
471
|
+
fn_args: Union[Arguments, Iterable[Any], Any],
|
|
472
|
+
) -> FutureLike[T]:
|
|
473
|
+
if is_batch_args:
|
|
474
|
+
future = executor.submit(fn, fn_args)
|
|
475
|
+
else:
|
|
476
|
+
if isinstance(fn_args, Arguments):
|
|
477
|
+
future = executor.submit(fn, *fn_args.args, **fn_args.kwargs)
|
|
478
|
+
elif isinstance(fn_args, Iterable) and extract_args:
|
|
479
|
+
future = executor.submit(fn, *fn_args)
|
|
480
|
+
else:
|
|
481
|
+
future = executor.submit(fn, fn_args)
|
|
482
|
+
return future
|
|
483
|
+
|
|
484
|
+
if on_return is None:
|
|
485
|
+
|
|
486
|
+
def handle_result(i: int, result: T) -> None:
|
|
487
|
+
nonlocal result_cursor, result_cursor_bound
|
|
488
|
+
|
|
489
|
+
if sort_by_completion:
|
|
490
|
+
# yield result in the order of completion
|
|
491
|
+
result_queue.put((i, result, False))
|
|
492
|
+
else:
|
|
493
|
+
# yield result in the order of submission
|
|
494
|
+
result_cursor_bound = max(result_cursor_bound, i)
|
|
495
|
+
result_cache[i] = result
|
|
496
|
+
while result_cursor <= result_cursor_bound:
|
|
497
|
+
if result_cursor not in result_cache:
|
|
498
|
+
return
|
|
499
|
+
result = result_cache.pop(result_cursor)
|
|
500
|
+
result_queue.put((result_cursor, result, False))
|
|
501
|
+
result_cursor += 1
|
|
502
|
+
|
|
503
|
+
def stop_result_handler() -> None: ...
|
|
504
|
+
|
|
505
|
+
else:
|
|
506
|
+
|
|
507
|
+
def handle_result_concurrently(inqueue: Queue, outqueue: Queue) -> None:
|
|
508
|
+
while True:
|
|
509
|
+
try:
|
|
510
|
+
pair: Optional[Tuple[int, T]] = inqueue.get(timeout=0.01)
|
|
511
|
+
except Empty:
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
if pair is None:
|
|
515
|
+
break
|
|
516
|
+
|
|
517
|
+
i, raw_result = pair
|
|
518
|
+
try:
|
|
519
|
+
result = on_return(raw_result) # type: ignore[misc]
|
|
520
|
+
outqueue.put((i, result, False))
|
|
521
|
+
except BaseException as exc:
|
|
522
|
+
outqueue.put((i, exc, True))
|
|
523
|
+
del exc
|
|
524
|
+
finally:
|
|
525
|
+
del raw_result, pair
|
|
526
|
+
|
|
527
|
+
raw_result_queue: Queue = Queue()
|
|
528
|
+
result_handler: Optional[Thread] = None
|
|
529
|
+
|
|
530
|
+
def stop_result_handler() -> None:
|
|
531
|
+
nonlocal result_handler
|
|
532
|
+
if result_handler is None or not result_handler.is_alive():
|
|
533
|
+
return
|
|
534
|
+
raw_result_queue.put(None)
|
|
535
|
+
result_handler.join()
|
|
536
|
+
result_handler = None
|
|
537
|
+
|
|
538
|
+
def handle_result(i: int, result: T) -> None:
|
|
539
|
+
nonlocal result_handler, result_cache
|
|
540
|
+
nonlocal result_cursor, result_cursor_bound
|
|
541
|
+
|
|
542
|
+
if result_handler is None:
|
|
543
|
+
result_handler = Thread(
|
|
544
|
+
target=handle_result_concurrently,
|
|
545
|
+
args=(raw_result_queue, result_queue),
|
|
546
|
+
daemon=True,
|
|
547
|
+
)
|
|
548
|
+
result_handler.start()
|
|
549
|
+
|
|
550
|
+
if sort_by_completion:
|
|
551
|
+
# yield result in the order of completion
|
|
552
|
+
raw_result_queue.put((i, result))
|
|
553
|
+
else:
|
|
554
|
+
# yield result in the order of submission
|
|
555
|
+
result_cursor_bound = max(result_cursor_bound, i)
|
|
556
|
+
result_cache[i] = result
|
|
557
|
+
while result_cursor <= result_cursor_bound:
|
|
558
|
+
if result_cursor not in result_cache:
|
|
559
|
+
return
|
|
560
|
+
result = result_cache.pop(result_cursor)
|
|
561
|
+
raw_result_queue.put((result_cursor, result))
|
|
562
|
+
result_cursor += 1
|
|
563
|
+
|
|
564
|
+
try:
|
|
565
|
+
# Indicates whether there are pending data in args_iter. At least one
|
|
566
|
+
# iteration should be invoked so the initial value is True and it will be set
|
|
567
|
+
# as False on StopIteration.
|
|
568
|
+
has_args = True
|
|
569
|
+
|
|
570
|
+
# Conditions to stop iteration (must meet all requirements):
|
|
571
|
+
# 1. No more data in args_iter (has_args is False)
|
|
572
|
+
# 2. The last batch has been submitted (batch is empty)
|
|
573
|
+
# 3. All results have been yielded (task_indices is empty)
|
|
574
|
+
while has_args or batch or task_indices:
|
|
575
|
+
if has_args and (num_prepare == 0 or len(futures) < num_prepare):
|
|
576
|
+
try:
|
|
577
|
+
fn_args = next(args_iter)
|
|
578
|
+
batch.append(fn_args)
|
|
579
|
+
if len(batch) >= batch_size:
|
|
580
|
+
futures[batch_cursor] = submit(
|
|
581
|
+
batch if is_batch_args else batch[0]
|
|
582
|
+
)
|
|
583
|
+
task_indices.add(batch_cursor)
|
|
584
|
+
batch_cursor += 1
|
|
585
|
+
batch = []
|
|
586
|
+
except StopIteration:
|
|
587
|
+
if batch:
|
|
588
|
+
futures[batch_cursor] = submit(
|
|
589
|
+
batch if is_batch_args else batch[0]
|
|
590
|
+
)
|
|
591
|
+
task_indices.add(batch_cursor)
|
|
592
|
+
batch_cursor += 1
|
|
593
|
+
batch = []
|
|
594
|
+
has_args = False
|
|
595
|
+
|
|
596
|
+
if futures:
|
|
597
|
+
finished_indices: List[int] = []
|
|
598
|
+
for i, future in futures.items():
|
|
599
|
+
if not future.done():
|
|
600
|
+
continue
|
|
601
|
+
finished_indices.append(i)
|
|
602
|
+
handle_result(i, future.result())
|
|
603
|
+
for i in finished_indices:
|
|
604
|
+
futures.pop(i)
|
|
605
|
+
|
|
606
|
+
while not result_queue.empty():
|
|
607
|
+
i, result, is_exc = result_queue.get()
|
|
608
|
+
task_indices.remove(i)
|
|
609
|
+
if is_exc:
|
|
610
|
+
stop_result_handler()
|
|
611
|
+
raise cast(BaseException, result)
|
|
612
|
+
else:
|
|
613
|
+
yield result
|
|
614
|
+
finally:
|
|
615
|
+
stop_result_handler()
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
@overload
|
|
619
|
+
def map(
|
|
620
|
+
fn: Callable[..., T],
|
|
621
|
+
args: Iterable[Any],
|
|
622
|
+
*more_args: Iterable[Any],
|
|
623
|
+
executor: Optional[SupportsSubmit] = None,
|
|
624
|
+
batch_size: Optional[int] = None,
|
|
625
|
+
num_prepare: Optional[int] = None,
|
|
626
|
+
sort_by_completion: bool = False,
|
|
627
|
+
) -> Iterator[T]: ...
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@overload
|
|
631
|
+
def map(
|
|
632
|
+
fn: Callable[..., T],
|
|
633
|
+
args: Iterable[Any],
|
|
634
|
+
*more_args: Iterable[Any],
|
|
635
|
+
executor: Optional[SupportsSubmit] = None,
|
|
636
|
+
batch_size: Optional[int] = None,
|
|
637
|
+
num_prepare: Optional[int] = None,
|
|
638
|
+
sort_by_completion: bool = False,
|
|
639
|
+
on_return: Callable[[T], R],
|
|
640
|
+
) -> Iterator[R]: ...
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def map(
|
|
644
|
+
fn: Callable[..., T],
|
|
645
|
+
args: Iterable[Any],
|
|
646
|
+
*more_args: Iterable[Any],
|
|
647
|
+
executor: Optional[SupportsSubmit] = None,
|
|
648
|
+
batch_size: Optional[int] = None,
|
|
649
|
+
chunk_size: Optional[int] = None,
|
|
650
|
+
num_prepare: Optional[int] = None,
|
|
651
|
+
sort_by_completion: bool = False,
|
|
652
|
+
on_return: Optional[Callable[[T], R]] = None,
|
|
653
|
+
) -> Iterator[Union[T, R]]:
|
|
654
|
+
"""Drop-in replacement of builtin ``map`` but adds an argument ``executor`` for
|
|
655
|
+
executing function in an executor.
|
|
656
|
+
|
|
657
|
+
:type fn: Callable[..., T]
|
|
658
|
+
:param fn: The function to be mapped.
|
|
659
|
+
|
|
660
|
+
:type args: Iterable[Any]
|
|
661
|
+
:param args: The arguments to be mapped into ``fn``.
|
|
662
|
+
|
|
663
|
+
:type executor: SupportsSubmit, optional, default to ``None``
|
|
664
|
+
:param executor: The executor to run function ``fn``. It supports not only an
|
|
665
|
+
implementation of ``concurrent.futures.Executor`` but also any instances that
|
|
666
|
+
have a method ``submit(fn, *args, **kwargs) -> Future``. The default value is
|
|
667
|
+
``None``, which means not to use an executor and this function will behave
|
|
668
|
+
as the builtin ``map`` does.
|
|
669
|
+
|
|
670
|
+
:type batch_size: int, optional, default to ``None``
|
|
671
|
+
:param batch_size: If batch size is given, then items in args will be aranged into
|
|
672
|
+
batches (a list of items) firstly before sent to ``fn``. For example,
|
|
673
|
+
``map(fn, (1, 2, 3, 4, 5), batch_size=2)`` means invoking 3 functions:
|
|
674
|
+
``fn([1, 2])``, ``fn([3, 4])`` and ``fn([5])``. Batch will be disabled if
|
|
675
|
+
``batch_size`` is set as ``None`` or a number which is <= 1.
|
|
676
|
+
|
|
677
|
+
:type chunk_size: int, optional, default to ``None``
|
|
678
|
+
:param chunk_size: If ``chunk_size`` is given, multiple arguments will be submitted
|
|
679
|
+
to the executor in chunks and will be executed sequentially in one executor
|
|
680
|
+
worker. ``chunk_size`` may be useful to reduce the time spent on worker
|
|
681
|
+
initialization.
|
|
682
|
+
|
|
683
|
+
Here is an example to illustrate the difference between ``batch_size`` and
|
|
684
|
+
``chunk_size``. Considering
|
|
685
|
+
``map(fn, (1, 2, 3, 4, 5), executor=some_executor)``:
|
|
686
|
+
|
|
687
|
+
*. If set ``batch_size=2``, ``map`` will send 3 batched invocations to executor,
|
|
688
|
+
``fn([1, 2])``, ``fn([3, 4])`` and ``fn([5])``, so ``fn`` needs to accept
|
|
689
|
+
list of integers as its arguments instead of single integer.
|
|
690
|
+
*. If set ``chunk_size=2``, ``map`` will send 3 chunked invocations to executor,
|
|
691
|
+
it firstly send ``1`` and ``2`` to a worker, the worker will invoke
|
|
692
|
+
``fn(1)`` and ``fn(2)`` sequentially; then ``3`` and ``4`` to another
|
|
693
|
+
worker; then finally ``5``. This keeps ``fn`` not to be refactored to accept
|
|
694
|
+
batch input.
|
|
695
|
+
*. ``batch_size`` and ``chunk_size`` can be used simultaneously. If set
|
|
696
|
+
``batch_size=2`` and ``chunk_size=3``, then ``[1, 2]``, ``[3, 4]`` and
|
|
697
|
+
``[5]`` will be sent to a worker in on chunk.
|
|
698
|
+
|
|
699
|
+
Note that ``chunk_size`` will be ignored if ``executor`` is ``None``.
|
|
700
|
+
|
|
701
|
+
:type num_prepare: int, optional, default to ``None``
|
|
702
|
+
:param num_prepare: Number of items or batches (if ``batch_size`` is enabled) to
|
|
703
|
+
be submitted to executor. This can be useful to prevent get too many data
|
|
704
|
+
from ``args`` and occupy huge memory. A typical usecase is setting
|
|
705
|
+
``num_prepare`` to a rational value if ``args`` is a iterable line reader
|
|
706
|
+
of a super huge text file. If executor is ``None``, and ``num_prepare`` is
|
|
707
|
+
larger than zero, a thread for yielding data will be started to non-blockingly
|
|
708
|
+
fetch the specified amount of items or batches.
|
|
709
|
+
|
|
710
|
+
:type sort_by_completion: bool, default to ``False``
|
|
711
|
+
:param sort_by_completion: If set to ``True``, then the result will be returned in
|
|
712
|
+
the order of their completion time. Otherwise returned in the order of
|
|
713
|
+
submission, which is the same as builtin ``map``.
|
|
714
|
+
|
|
715
|
+
:type on_return: Callable[[T], U], optional, default to ``None``
|
|
716
|
+
:param on_return: An optional callback to be invoked on each result being returned.
|
|
717
|
+
It accept each result as its sole argument and its return will be treated as
|
|
718
|
+
as the final return of each invocation of ``fn``.
|
|
719
|
+
|
|
720
|
+
:rtype: Iterator
|
|
721
|
+
:return: An iterator which yield all results of ``fn``.
|
|
722
|
+
"""
|
|
723
|
+
|
|
724
|
+
for fn_args in (args, *more_args):
|
|
725
|
+
if not isinstance(fn_args, Iterable):
|
|
726
|
+
raise TypeError(
|
|
727
|
+
"Positional arguments after function should be iterable, "
|
|
728
|
+
f"got {fn_args} ({type(fn_args)}) instead."
|
|
729
|
+
)
|
|
730
|
+
if batch_size is None or batch_size <= 1:
|
|
731
|
+
batch_size = 0
|
|
732
|
+
if num_prepare is None or num_prepare <= 0:
|
|
733
|
+
num_prepare = 0
|
|
734
|
+
|
|
735
|
+
if executor is None:
|
|
736
|
+
return _simple_map(
|
|
737
|
+
fn,
|
|
738
|
+
args,
|
|
739
|
+
*more_args,
|
|
740
|
+
batch_size=batch_size,
|
|
741
|
+
num_prepare=num_prepare,
|
|
742
|
+
on_return=on_return, # type: ignore[arg-type]
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
if chunk_size is None or chunk_size <= 1:
|
|
746
|
+
return _concurrent_map(
|
|
747
|
+
fn,
|
|
748
|
+
args,
|
|
749
|
+
*more_args,
|
|
750
|
+
executor=executor,
|
|
751
|
+
batch_size=batch_size,
|
|
752
|
+
num_prepare=num_prepare,
|
|
753
|
+
sort_by_completion=sort_by_completion,
|
|
754
|
+
on_return=on_return, # type: ignore[arg-type]
|
|
755
|
+
)
|
|
756
|
+
return _concurrent_chunk_map(
|
|
757
|
+
fn,
|
|
758
|
+
args,
|
|
759
|
+
*more_args,
|
|
760
|
+
executor=executor,
|
|
761
|
+
batch_size=batch_size,
|
|
762
|
+
chunk_size=chunk_size,
|
|
763
|
+
num_prepare=num_prepare,
|
|
764
|
+
sort_by_completion=sort_by_completion,
|
|
765
|
+
on_return=on_return, # type: ignore[arg-type]
|
|
766
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fluentmap"
|
|
3
|
+
description = "Drop-in replacement for Python map with concurrency support"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name = "Leavers", email = "leavers930@gmail.com" },
|
|
6
|
+
]
|
|
7
|
+
dependencies = []
|
|
8
|
+
requires-python = ">=3.8"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
dynamic = []
|
|
11
|
+
version = "0.0.1"
|
|
12
|
+
|
|
13
|
+
[project.license]
|
|
14
|
+
text = "MIT"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = [
|
|
18
|
+
"pdm-backend",
|
|
19
|
+
]
|
|
20
|
+
build-backend = "pdm.backend"
|
|
21
|
+
|
|
22
|
+
[tool.pdm]
|
|
23
|
+
distribution = true
|
|
24
|
+
|
|
25
|
+
[tool.pdm.build]
|
|
26
|
+
includes = [
|
|
27
|
+
"fluentmap.py",
|
|
28
|
+
]
|
|
29
|
+
excludes = [
|
|
30
|
+
"tests/",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.pdm.version]
|
|
34
|
+
source = "file"
|
|
35
|
+
path = "fluentmap.py"
|
|
36
|
+
|
|
37
|
+
[tool.pdm.dev-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"autoflake>=2.3.1",
|
|
40
|
+
"mypy>=1.11.1",
|
|
41
|
+
"nox>=2024.4.15",
|
|
42
|
+
"pytest>=8.3.2",
|
|
43
|
+
"ruff>=0.6.1",
|
|
44
|
+
"rtoml>=0.11.0",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[tool.autoflake]
|
|
48
|
+
in-place = true
|
|
49
|
+
recursive = true
|
|
50
|
+
remove-all-unused-imports = true
|
|
51
|
+
ignore-init-module-imports = true
|
|
52
|
+
remove-duplicate-keys = true
|
|
53
|
+
remove-unused-variables = true
|
|
54
|
+
|
|
55
|
+
[tool.coverage.run]
|
|
56
|
+
branch = true
|
|
57
|
+
source = [
|
|
58
|
+
"fluentmap.py",
|
|
59
|
+
]
|
|
60
|
+
parallel = true
|
|
61
|
+
|
|
62
|
+
[tool.coverage.report]
|
|
63
|
+
show_missing = true
|
|
64
|
+
exclude_lines = [
|
|
65
|
+
"pragma: no cover",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
line-length = 88
|
|
70
|
+
|
|
71
|
+
[tool.mypy]
|
|
72
|
+
check_untyped_defs = true
|
|
73
|
+
follow_imports = "error"
|
|
74
|
+
ignore_missing_imports = true
|
|
75
|
+
show_error_codes = true
|
|
76
|
+
warn_unused_configs = true
|
|
77
|
+
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
asyncio_mode = "strict"
|