haiway 0.24.0__py3-none-any.whl → 0.24.2__py3-none-any.whl
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.
- haiway/__init__.py +4 -0
- haiway/helpers/__init__.py +7 -1
- haiway/helpers/concurrent.py +324 -15
- {haiway-0.24.0.dist-info → haiway-0.24.2.dist-info}/METADATA +1 -1
- {haiway-0.24.0.dist-info → haiway-0.24.2.dist-info}/RECORD +7 -7
- {haiway-0.24.0.dist-info → haiway-0.24.2.dist-info}/WHEEL +0 -0
- {haiway-0.24.0.dist-info → haiway-0.24.2.dist-info}/licenses/LICENSE +0 -0
haiway/__init__.py
CHANGED
@@ -18,8 +18,10 @@ from haiway.helpers import (
|
|
18
18
|
LoggerObservability,
|
19
19
|
asynchronous,
|
20
20
|
cache,
|
21
|
+
execute_concurrently,
|
21
22
|
process_concurrently,
|
22
23
|
retry,
|
24
|
+
stream_concurrently,
|
23
25
|
throttle,
|
24
26
|
timeout,
|
25
27
|
traced,
|
@@ -90,6 +92,7 @@ __all__ = (
|
|
90
92
|
"asynchronous",
|
91
93
|
"cache",
|
92
94
|
"ctx",
|
95
|
+
"execute_concurrently",
|
93
96
|
"getenv",
|
94
97
|
"getenv_base64",
|
95
98
|
"getenv_bool",
|
@@ -103,6 +106,7 @@ __all__ = (
|
|
103
106
|
"process_concurrently",
|
104
107
|
"retry",
|
105
108
|
"setup_logging",
|
109
|
+
"stream_concurrently",
|
106
110
|
"throttle",
|
107
111
|
"timeout",
|
108
112
|
"traced",
|
haiway/helpers/__init__.py
CHANGED
@@ -1,6 +1,10 @@
|
|
1
1
|
from haiway.helpers.asynchrony import asynchronous
|
2
2
|
from haiway.helpers.caching import CacheMakeKey, CacheRead, CacheWrite, cache
|
3
|
-
from haiway.helpers.concurrent import
|
3
|
+
from haiway.helpers.concurrent import (
|
4
|
+
execute_concurrently,
|
5
|
+
process_concurrently,
|
6
|
+
stream_concurrently,
|
7
|
+
)
|
4
8
|
from haiway.helpers.files import File, FileAccess
|
5
9
|
from haiway.helpers.observability import LoggerObservability
|
6
10
|
from haiway.helpers.retries import retry
|
@@ -17,8 +21,10 @@ __all__ = (
|
|
17
21
|
"LoggerObservability",
|
18
22
|
"asynchronous",
|
19
23
|
"cache",
|
24
|
+
"execute_concurrently",
|
20
25
|
"process_concurrently",
|
21
26
|
"retry",
|
27
|
+
"stream_concurrently",
|
22
28
|
"throttle",
|
23
29
|
"timeout",
|
24
30
|
"traced",
|
haiway/helpers/concurrent.py
CHANGED
@@ -1,11 +1,22 @@
|
|
1
|
-
from asyncio import FIRST_COMPLETED, CancelledError, Task, wait
|
2
|
-
from collections.abc import
|
3
|
-
|
4
|
-
|
1
|
+
from asyncio import ALL_COMPLETED, FIRST_COMPLETED, CancelledError, Task, wait
|
2
|
+
from collections.abc import (
|
3
|
+
AsyncIterable,
|
4
|
+
AsyncIterator,
|
5
|
+
Callable,
|
6
|
+
Collection,
|
7
|
+
Coroutine,
|
8
|
+
MutableSequence,
|
9
|
+
Sequence,
|
10
|
+
)
|
11
|
+
from typing import Any, Literal, overload
|
5
12
|
|
6
13
|
from haiway.context import ctx
|
7
14
|
|
8
|
-
__all__ = (
|
15
|
+
__all__ = (
|
16
|
+
"execute_concurrently",
|
17
|
+
"process_concurrently",
|
18
|
+
"stream_concurrently",
|
19
|
+
)
|
9
20
|
|
10
21
|
|
11
22
|
async def process_concurrently[Element]( # noqa: C901, PLR0912
|
@@ -18,20 +29,52 @@ async def process_concurrently[Element]( # noqa: C901, PLR0912
|
|
18
29
|
) -> None:
|
19
30
|
"""Process elements from an async iterator concurrently.
|
20
31
|
|
32
|
+
Consumes elements from an async iterator and processes them using the provided
|
33
|
+
handler function. Processing happens concurrently with a configurable maximum
|
34
|
+
number of concurrent tasks. Elements are processed as they become available,
|
35
|
+
maintaining the specified concurrency limit.
|
36
|
+
|
37
|
+
The function continues until the source iterator is exhausted. If the function
|
38
|
+
is cancelled, all running tasks are also cancelled. When ignore_exceptions is
|
39
|
+
False, the first exception encountered will stop processing and propagate.
|
40
|
+
|
21
41
|
Parameters
|
22
42
|
----------
|
23
|
-
source: AsyncIterator[Element]
|
24
|
-
An async iterator providing elements to process.
|
43
|
+
source : AsyncIterator[Element]
|
44
|
+
An async iterator providing elements to process. Elements are consumed
|
45
|
+
one at a time as processing slots become available.
|
46
|
+
handler : Callable[[Element], Coroutine[Any, Any, None]]
|
47
|
+
A coroutine function that processes each element. The handler should
|
48
|
+
not return a value (returns None).
|
49
|
+
concurrent_tasks : int, default=2
|
50
|
+
Maximum number of concurrent tasks. Must be greater than 0. Higher
|
51
|
+
values allow more parallelism but consume more resources.
|
52
|
+
ignore_exceptions : bool, default=False
|
53
|
+
If True, exceptions from handler tasks will be logged but not propagated,
|
54
|
+
allowing processing to continue. If False, the first exception stops
|
55
|
+
all processing.
|
25
56
|
|
26
|
-
|
27
|
-
|
57
|
+
Raises
|
58
|
+
------
|
59
|
+
CancelledError
|
60
|
+
If the function is cancelled, propagated after cancelling all running tasks.
|
61
|
+
Exception
|
62
|
+
Any exception raised by handler tasks when ignore_exceptions is False.
|
28
63
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
64
|
+
Examples
|
65
|
+
--------
|
66
|
+
>>> async def process_item(item: str) -> None:
|
67
|
+
... await some_async_operation(item)
|
68
|
+
...
|
69
|
+
>>> async def items() -> AsyncIterator[str]:
|
70
|
+
... for i in range(10):
|
71
|
+
... yield f"item_{i}"
|
72
|
+
...
|
73
|
+
>>> await process_concurrently(
|
74
|
+
... items(),
|
75
|
+
... process_item,
|
76
|
+
... concurrent_tasks=5
|
77
|
+
... )
|
35
78
|
|
36
79
|
"""
|
37
80
|
assert concurrent_tasks > 0 # nosec: B101
|
@@ -83,3 +126,269 @@ async def process_concurrently[Element]( # noqa: C901, PLR0912
|
|
83
126
|
f"Concurrent processing error - {type(exc)}: {exc}",
|
84
127
|
exception=exc,
|
85
128
|
)
|
129
|
+
|
130
|
+
|
131
|
+
@overload
|
132
|
+
async def execute_concurrently[Element, Result](
|
133
|
+
source: Collection[Element],
|
134
|
+
/,
|
135
|
+
handler: Callable[[Element], Coroutine[Any, Any, Result]],
|
136
|
+
*,
|
137
|
+
concurrent_tasks: int = 2,
|
138
|
+
) -> Sequence[Result]: ...
|
139
|
+
|
140
|
+
|
141
|
+
@overload
|
142
|
+
async def execute_concurrently[Element, Result](
|
143
|
+
source: Collection[Element],
|
144
|
+
/,
|
145
|
+
handler: Callable[[Element], Coroutine[Any, Any, Result]],
|
146
|
+
*,
|
147
|
+
concurrent_tasks: int = 2,
|
148
|
+
return_exceptions: Literal[True],
|
149
|
+
) -> Sequence[Result | BaseException]: ...
|
150
|
+
|
151
|
+
|
152
|
+
async def execute_concurrently[Element, Result]( # noqa: C901
|
153
|
+
source: Collection[Element],
|
154
|
+
/,
|
155
|
+
handler: Callable[[Element], Coroutine[Any, Any, Result]],
|
156
|
+
*,
|
157
|
+
concurrent_tasks: int = 2,
|
158
|
+
return_exceptions: bool = False,
|
159
|
+
) -> Sequence[Result | BaseException] | Sequence[Result]:
|
160
|
+
"""Execute handler for each element from a collection concurrently.
|
161
|
+
|
162
|
+
Processes all elements from a collection using the provided handler function,
|
163
|
+
executing multiple handlers concurrently up to the specified limit. Results
|
164
|
+
are collected and returned in the same order as the input elements.
|
165
|
+
|
166
|
+
Unlike `process_concurrently`, this function:
|
167
|
+
- Works with collections (known size) rather than async iterators
|
168
|
+
- Returns results from each handler invocation
|
169
|
+
- Preserves the order of results to match input order
|
170
|
+
|
171
|
+
The function ensures all tasks complete before returning. If cancelled,
|
172
|
+
all running tasks are cancelled before propagating the cancellation.
|
173
|
+
|
174
|
+
Parameters
|
175
|
+
----------
|
176
|
+
source : Collection[Element]
|
177
|
+
A collection of elements to process. The collection size determines
|
178
|
+
the result sequence length.
|
179
|
+
handler : Callable[[Element], Coroutine[Any, Any, Result]]
|
180
|
+
A coroutine function that processes each element and returns a result.
|
181
|
+
concurrent_tasks : int, default=2
|
182
|
+
Maximum number of concurrent tasks. Must be greater than 0. Higher
|
183
|
+
values allow more parallelism but consume more resources.
|
184
|
+
return_exceptions : bool, default=False
|
185
|
+
If True, exceptions from handler tasks are included in the results
|
186
|
+
as BaseException instances. If False, the first exception stops
|
187
|
+
processing and is raised.
|
188
|
+
|
189
|
+
Returns
|
190
|
+
-------
|
191
|
+
Sequence[Result] or Sequence[Result | BaseException]
|
192
|
+
Results from each handler invocation, in the same order as input elements.
|
193
|
+
If return_exceptions is True, failed tasks return BaseException instances.
|
194
|
+
|
195
|
+
Raises
|
196
|
+
------
|
197
|
+
CancelledError
|
198
|
+
If the function is cancelled, propagated after cancelling all running tasks.
|
199
|
+
Exception
|
200
|
+
Any exception raised by handler tasks when return_exceptions is False.
|
201
|
+
|
202
|
+
Examples
|
203
|
+
--------
|
204
|
+
>>> async def fetch_data(url: str) -> dict:
|
205
|
+
... return await http_client.get(url)
|
206
|
+
...
|
207
|
+
>>> urls = ["http://api.example.com/1", "http://api.example.com/2"]
|
208
|
+
>>> results = await execute_concurrently(
|
209
|
+
... urls,
|
210
|
+
... fetch_data,
|
211
|
+
... concurrent_tasks=10
|
212
|
+
... )
|
213
|
+
>>> # results[0] corresponds to urls[0], results[1] to urls[1], etc.
|
214
|
+
|
215
|
+
>>> # With exception handling
|
216
|
+
>>> results = await execute_concurrently(
|
217
|
+
... urls,
|
218
|
+
... fetch_data,
|
219
|
+
... concurrent_tasks=10,
|
220
|
+
... return_exceptions=True
|
221
|
+
... )
|
222
|
+
>>> for url, result in zip(urls, results):
|
223
|
+
... if isinstance(result, BaseException):
|
224
|
+
... print(f"Failed to fetch {url}: {result}")
|
225
|
+
... else:
|
226
|
+
... print(f"Got data from {url}")
|
227
|
+
|
228
|
+
"""
|
229
|
+
assert concurrent_tasks > 0 # nosec: B101
|
230
|
+
running: set[Task[Result]] = set()
|
231
|
+
results: MutableSequence[Task[Result]] = []
|
232
|
+
try:
|
233
|
+
for element in source:
|
234
|
+
task: Task[Result] = ctx.spawn(handler, element)
|
235
|
+
results.append(task)
|
236
|
+
running.add(task)
|
237
|
+
if len(running) < concurrent_tasks:
|
238
|
+
continue # keep spawning tasks
|
239
|
+
|
240
|
+
completed, running = await wait(
|
241
|
+
running,
|
242
|
+
return_when=FIRST_COMPLETED,
|
243
|
+
)
|
244
|
+
|
245
|
+
for task in completed:
|
246
|
+
if exc := task.exception():
|
247
|
+
if not return_exceptions:
|
248
|
+
raise exc
|
249
|
+
|
250
|
+
ctx.log_error(
|
251
|
+
f"Concurrent execution error - {type(exc)}: {exc}",
|
252
|
+
exception=exc,
|
253
|
+
)
|
254
|
+
|
255
|
+
except CancelledError as exc:
|
256
|
+
# Cancel all running tasks
|
257
|
+
for task in running:
|
258
|
+
task.cancel()
|
259
|
+
|
260
|
+
raise exc
|
261
|
+
|
262
|
+
finally:
|
263
|
+
if running:
|
264
|
+
completed, _ = await wait(
|
265
|
+
running,
|
266
|
+
return_when=ALL_COMPLETED,
|
267
|
+
)
|
268
|
+
for task in completed:
|
269
|
+
if exc := task.exception():
|
270
|
+
if not return_exceptions:
|
271
|
+
raise exc
|
272
|
+
|
273
|
+
ctx.log_error(
|
274
|
+
f"Concurrent execution error - {type(exc)}: {exc}",
|
275
|
+
exception=exc,
|
276
|
+
)
|
277
|
+
|
278
|
+
return [result.exception() or result.result() for result in results]
|
279
|
+
|
280
|
+
|
281
|
+
async def stream_concurrently[ElementA, ElementB]( # noqa: C901
|
282
|
+
source_a: AsyncIterator[ElementA],
|
283
|
+
source_b: AsyncIterator[ElementB],
|
284
|
+
/,
|
285
|
+
) -> AsyncIterable[ElementA | ElementB]:
|
286
|
+
"""Merge streams from two async iterators processed concurrently.
|
287
|
+
|
288
|
+
Concurrently consumes elements from two async iterators and yields them
|
289
|
+
as they become available. Elements from both sources are interleaved based
|
290
|
+
on which iterator produces them first. The function continues until both
|
291
|
+
iterators are exhausted.
|
292
|
+
|
293
|
+
This is useful for combining multiple async data sources into a single
|
294
|
+
stream while maintaining concurrency. Each iterator is polled independently,
|
295
|
+
and whichever has data available first will have its element yielded.
|
296
|
+
|
297
|
+
Parameters
|
298
|
+
----------
|
299
|
+
source_a : AsyncIterator[ElementA]
|
300
|
+
First async iterator to consume from.
|
301
|
+
source_b : AsyncIterator[ElementB]
|
302
|
+
Second async iterator to consume from.
|
303
|
+
|
304
|
+
Yields
|
305
|
+
------
|
306
|
+
ElementA | ElementB
|
307
|
+
Elements from either source as they become available. The order
|
308
|
+
depends on which iterator produces elements first.
|
309
|
+
|
310
|
+
Raises
|
311
|
+
------
|
312
|
+
CancelledError
|
313
|
+
If the async generator is cancelled, both source tasks are cancelled
|
314
|
+
before propagating the cancellation.
|
315
|
+
Exception
|
316
|
+
Any exception raised by either source iterator.
|
317
|
+
|
318
|
+
Examples
|
319
|
+
--------
|
320
|
+
>>> async def numbers() -> AsyncIterator[int]:
|
321
|
+
... for i in range(5):
|
322
|
+
... await asyncio.sleep(0.1)
|
323
|
+
... yield i
|
324
|
+
...
|
325
|
+
>>> async def letters() -> AsyncIterator[str]:
|
326
|
+
... for c in "abcde":
|
327
|
+
... await asyncio.sleep(0.15)
|
328
|
+
... yield c
|
329
|
+
...
|
330
|
+
>>> async for item in stream_concurrently(numbers(), letters()):
|
331
|
+
... print(item) # Prints interleaved numbers and letters
|
332
|
+
|
333
|
+
Notes
|
334
|
+
-----
|
335
|
+
The function maintains exactly one pending task per iterator at all times,
|
336
|
+
ensuring efficient resource usage while maximizing throughput from both
|
337
|
+
sources.
|
338
|
+
|
339
|
+
"""
|
340
|
+
|
341
|
+
async def next_a() -> ElementA:
|
342
|
+
return await anext(source_a)
|
343
|
+
|
344
|
+
async def next_b() -> ElementB:
|
345
|
+
return await anext(source_b)
|
346
|
+
|
347
|
+
task_a: Task[ElementA] = ctx.spawn(next_a)
|
348
|
+
task_b: Task[ElementB] = ctx.spawn(next_b)
|
349
|
+
|
350
|
+
try:
|
351
|
+
while not ( # Continue until both iterators are exhausted
|
352
|
+
task_a.done()
|
353
|
+
and task_b.done()
|
354
|
+
and isinstance(task_a.exception(), StopAsyncIteration)
|
355
|
+
and isinstance(task_b.exception(), StopAsyncIteration)
|
356
|
+
):
|
357
|
+
# Wait for at least one task to complete
|
358
|
+
done, _ = await wait(
|
359
|
+
{task_a, task_b},
|
360
|
+
return_when=FIRST_COMPLETED,
|
361
|
+
)
|
362
|
+
|
363
|
+
# Process completed tasks
|
364
|
+
for task in done:
|
365
|
+
if task is task_a:
|
366
|
+
exc: BaseException | None = task.exception()
|
367
|
+
if exc is None:
|
368
|
+
yield task.result()
|
369
|
+
task_a = ctx.spawn(next_a)
|
370
|
+
|
371
|
+
elif not isinstance(exc, StopAsyncIteration):
|
372
|
+
raise exc
|
373
|
+
# If StopAsyncIteration, don't respawn task_a
|
374
|
+
|
375
|
+
elif task is task_b:
|
376
|
+
exc: BaseException | None = task.exception()
|
377
|
+
if exc is None:
|
378
|
+
yield task.result()
|
379
|
+
task_b = ctx.spawn(next_b)
|
380
|
+
|
381
|
+
elif not isinstance(exc, StopAsyncIteration):
|
382
|
+
raise exc
|
383
|
+
# If StopAsyncIteration, don't respawn task_b
|
384
|
+
|
385
|
+
except CancelledError as exc:
|
386
|
+
# Cancel all running tasks
|
387
|
+
task_a.cancel()
|
388
|
+
task_b.cancel()
|
389
|
+
raise exc
|
390
|
+
|
391
|
+
finally:
|
392
|
+
# Ensure cleanup of any remaining tasks
|
393
|
+
for task in (task_a, task_b):
|
394
|
+
task.cancel()
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: haiway
|
3
|
-
Version: 0.24.
|
3
|
+
Version: 0.24.2
|
4
4
|
Summary: Framework for dependency injection and state management within structured concurrency model.
|
5
5
|
Project-URL: Homepage, https://miquido.com
|
6
6
|
Project-URL: Repository, https://github.com/miquido/haiway.git
|
@@ -1,4 +1,4 @@
|
|
1
|
-
haiway/__init__.py,sha256=
|
1
|
+
haiway/__init__.py,sha256=5-zrkIqG6vMupL_Oh_KPqGXaEqAVj1FSed4BK_x0Nv0,2053
|
2
2
|
haiway/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
3
|
haiway/context/__init__.py,sha256=1N_SvdPkTfIZDZybm3y0rY2dGrDLWTm0ryzUz2XD4f8,1174
|
4
4
|
haiway/context/access.py,sha256=lsK6RnSjaGRgdOprtLCz4vyw5KqMtbGs0uFXnJWc_j4,24446
|
@@ -8,10 +8,10 @@ haiway/context/observability.py,sha256=JGiBScElJMgYDxi1PoIx7K98PpCXTVV3WY-x8abLx
|
|
8
8
|
haiway/context/state.py,sha256=lzkVVdTMYn-Bfon2aCPMx6vRScrbOqY2f_DuS5aMx0s,11982
|
9
9
|
haiway/context/tasks.py,sha256=pScFgeiyrXSJRDFZiYbBLi3k_DHkSlhB8rgAnYtgyrU,4925
|
10
10
|
haiway/context/types.py,sha256=LoW8238TTdbUgmxyHDi0LVc8M8ZwTHLWKkAPttTsTeg,746
|
11
|
-
haiway/helpers/__init__.py,sha256=
|
11
|
+
haiway/helpers/__init__.py,sha256=J1WQdI2jD_zDP4azn9Me6hVvaBtz8kh9kTN-jDgDA5U,836
|
12
12
|
haiway/helpers/asynchrony.py,sha256=Ddj8UdXhVczAbAC-rLpyhWa4RJ_W2Eolo45Veorq7_4,5362
|
13
13
|
haiway/helpers/caching.py,sha256=BqgcUGQSAmXsuLi5V8EwlZzuGyutHOn1V4k7BHsGKeg,14347
|
14
|
-
haiway/helpers/concurrent.py,sha256=
|
14
|
+
haiway/helpers/concurrent.py,sha256=P8YXukabb29iQhSKTECVaThPhzTX17JDdKrWAjHy4d4,13105
|
15
15
|
haiway/helpers/files.py,sha256=L6vXd8gdgWx5jPL8azloU8IGoFq2xnxjMc4ufz-gdl4,11650
|
16
16
|
haiway/helpers/observability.py,sha256=jCJzOPJ5E3RKJsbbGRR1O-mZydaHNIGkIpppOH7nFBA,11012
|
17
17
|
haiway/helpers/retries.py,sha256=OH__I9e-PUFxcSwuQLIzJ9F1MwXgbz1Ur4jEjJiOmjQ,8974
|
@@ -39,7 +39,7 @@ haiway/utils/mimic.py,sha256=xaZiUKp096QFfdSw7cNIKEWt2UIS7vf880KF54gny38,1831
|
|
39
39
|
haiway/utils/noop.py,sha256=U8ocfoCgt-pY0owJDPtrRrj53cabeIXH9qCKWMQnoRk,1336
|
40
40
|
haiway/utils/queue.py,sha256=6v2u3pA6A44IuCCTOjmCt3yLyOcm7PCRnrIGo25j-1o,6402
|
41
41
|
haiway/utils/stream.py,sha256=lXaeveTY0-AYG5xVzcQYaiC6SUD5fUtHoMXiQcrQAAM,5723
|
42
|
-
haiway-0.24.
|
43
|
-
haiway-0.24.
|
44
|
-
haiway-0.24.
|
45
|
-
haiway-0.24.
|
42
|
+
haiway-0.24.2.dist-info/METADATA,sha256=nNfy1ktM-nzZnRYVfAz7QvTBQRZQqE_-NuUm5dz4NWs,4919
|
43
|
+
haiway-0.24.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
44
|
+
haiway-0.24.2.dist-info/licenses/LICENSE,sha256=3phcpHVNBP8jsi77gOO0E7rgKeDeu99Pi7DSnK9YHoQ,1069
|
45
|
+
haiway-0.24.2.dist-info/RECORD,,
|
File without changes
|
File without changes
|